3 Commits

Author SHA1 Message Date
3164a70e48 Ep13 publish, MLX whisper, voicemail system, hero redesign, massive topic expansion
- Switch whisper transcription from faster-whisper (CPU) to lightning-whisper-mlx (GPU)
- Fix word_timestamps hanging, use ffprobe for accurate duration
- Add Cloudflare Pages Worker for SignalWire voicemail fallback when server offline
- Add voicemail sync on startup, delete tracking, save feature
- Add /feed RSS proxy to _worker.js (was broken by worker taking over routing)
- Redesign website hero section: ghost buttons, compact phone, plain text links
- Rewrite caller prompts for faster point-getting and host-following
- Expand TOPIC_CALLIN from ~250 to 547 entries across 34 categories
- Add new categories: biology, psychology, engineering, math, geology, animals,
  work, money, books, movies, relationships, health, language, true crime,
  drunk/high/unhinged callers
- Remove bad Inworld voices (Pixie, Dominus), reduce repeat caller frequency
- Add audio monitor device routing, uvicorn --reload-dir fix
- Publish episode 13

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:56:47 -07:00
8d3d67a177 Add automated social clips section to how-it-works page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 04:43:31 -07:00
f9985fc693 Add direct Bluesky upload via atproto, bypass broken Postiz video
Postiz has a bug where Bluesky video uploads fail with "missing jobId".
This adds direct upload to Bluesky using the atproto SDK and the
video.bsky.app processing pipeline. Other platforms still use Postiz.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 04:34:15 -07:00
24 changed files with 3389 additions and 512 deletions

View File

@@ -24,9 +24,8 @@
## Running the App
```bash
# Start backend
cd /Users/lukemacneil/ai-podcast
python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
# Start backend — ALWAYS use --reload-dir to avoid CPU thrashing from file watchers
python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
# Or use run.sh
./run.sh

View File

@@ -7,5 +7,7 @@
"music_channel": 5,
"sfx_channel": 7,
"ad_channel": 11,
"monitor_device": 14,
"monitor_channel": 1,
"phone_filter": false
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,15 +19,17 @@ class AudioService:
def __init__(self):
# Device configuration
self.input_device: Optional[int] = None
self.input_device: Optional[int] = 13 # Radio Voice Mic (loopback input)
self.input_channel: int = 1 # 1-indexed channel
self.output_device: Optional[int] = None # Single output device (multi-channel)
self.caller_channel: int = 1 # Channel for caller TTS
self.output_device: Optional[int] = 12 # Radio Voice Mic (loopback output)
self.caller_channel: int = 3 # Channel for caller TTS
self.live_caller_channel: int = 9 # Channel for live caller audio
self.music_channel: int = 2 # Channel for music
self.music_channel: int = 5 # Channel for music
self.sfx_channel: int = 3 # Channel for SFX
self.ad_channel: int = 11 # Channel for ads
self.monitor_device: Optional[int] = 14 # Babyface Pro (headphone monitoring)
self.monitor_channel: int = 1 # Channel for mic monitoring on monitor device
self.phone_filter: bool = False # Phone filter on caller voices
# Ad playback state
@@ -78,6 +80,10 @@ class AudioService:
self.input_sample_rate = 16000 # For Whisper
self.output_sample_rate = 24000 # For TTS
# Mic monitor (input → monitor device passthrough)
self._monitor_stream: Optional[sd.OutputStream] = None
self._monitor_write: Optional[Callable] = None
# Stem recording (opt-in, attached via API)
self.stem_recorder = None
self._stem_mic_stream: Optional[sd.InputStream] = None
@@ -99,8 +105,10 @@ class AudioService:
self.music_channel = data.get("music_channel", 2)
self.sfx_channel = data.get("sfx_channel", 3)
self.ad_channel = data.get("ad_channel", 11)
self.monitor_device = data.get("monitor_device")
self.monitor_channel = data.get("monitor_channel", 1)
self.phone_filter = data.get("phone_filter", False)
print(f"Loaded audio settings: output={self.output_device}, channels={self.caller_channel}/{self.live_caller_channel}/{self.music_channel}/{self.sfx_channel}/ad:{self.ad_channel}, phone_filter={self.phone_filter}")
print(f"Loaded audio settings: input={self.input_device}, output={self.output_device}, monitor={self.monitor_device}, phone_filter={self.phone_filter}")
except Exception as e:
print(f"Failed to load audio settings: {e}")
@@ -116,6 +124,8 @@ class AudioService:
"music_channel": self.music_channel,
"sfx_channel": self.sfx_channel,
"ad_channel": self.ad_channel,
"monitor_device": self.monitor_device,
"monitor_channel": self.monitor_channel,
"phone_filter": self.phone_filter,
}
with open(SETTINGS_FILE, "w") as f:
@@ -148,6 +158,8 @@ class AudioService:
music_channel: Optional[int] = None,
sfx_channel: Optional[int] = None,
ad_channel: Optional[int] = None,
monitor_device: Optional[int] = None,
monitor_channel: Optional[int] = None,
phone_filter: Optional[bool] = None
):
"""Configure audio devices and channels"""
@@ -167,6 +179,10 @@ class AudioService:
self.sfx_channel = sfx_channel
if ad_channel is not None:
self.ad_channel = ad_channel
if monitor_device is not None:
self.monitor_device = monitor_device
if monitor_channel is not None:
self.monitor_channel = monitor_channel
if phone_filter is not None:
self.phone_filter = phone_filter
@@ -184,6 +200,8 @@ class AudioService:
"music_channel": self.music_channel,
"sfx_channel": self.sfx_channel,
"ad_channel": self.ad_channel,
"monitor_device": self.monitor_device,
"monitor_channel": self.monitor_channel,
"phone_filter": self.phone_filter,
}
@@ -542,6 +560,9 @@ class AudioService:
host_accum_samples = [0]
send_threshold = 1600 # 100ms at 16kHz
# Start mic monitor if monitor device is configured
self._start_monitor(device_sr)
def callback(indata, frames, time_info, status):
# Capture for push-to-talk recording if active
if self._recording and self._recorded_audio is not None:
@@ -551,6 +572,10 @@ class AudioService:
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
# Mic monitor: send to headphone device
if self._monitor_write:
self._monitor_write(indata[:, record_channel].copy())
if not self._host_send_callback:
return
mono = indata[:, record_channel]
@@ -591,8 +616,84 @@ class AudioService:
self._host_stream = None
self._host_send_callback = None
print("[Audio] Host mic streaming stopped")
self._stop_monitor()
self._stop_live_caller_stream()
# --- Mic Monitor (input → headphone device) ---
def _start_monitor(self, input_sr: int):
"""Start mic monitor stream that routes input to monitor device"""
if self._monitor_stream is not None:
return
if self.monitor_device is None:
return
device_info = sd.query_devices(self.monitor_device)
num_channels = device_info['max_output_channels']
device_sr = int(device_info['default_samplerate'])
channel_idx = min(self.monitor_channel, num_channels) - 1
# Ring buffer for cross-device routing
ring_size = int(device_sr * 2)
ring = np.zeros(ring_size, dtype=np.float32)
state = {"write_pos": 0, "read_pos": 0, "avail": 0}
# Precompute resample ratio (input device sr → monitor device sr)
resample_ratio = device_sr / input_sr
def write_audio(data):
# Resample if sample rates differ
if abs(resample_ratio - 1.0) > 0.01:
n_out = int(len(data) * resample_ratio)
indices = np.linspace(0, len(data) - 1, n_out).astype(int)
data = data[indices]
n = len(data)
wp = state["write_pos"]
if wp + n <= ring_size:
ring[wp:wp + n] = data
else:
first = ring_size - wp
ring[wp:] = data[:first]
ring[:n - first] = data[first:]
state["write_pos"] = (wp + n) % ring_size
state["avail"] += n
def callback(outdata, frames, time_info, status):
outdata.fill(0)
avail = state["avail"]
if avail < frames:
return
rp = state["read_pos"]
if rp + frames <= ring_size:
outdata[:frames, channel_idx] = ring[rp:rp + frames]
else:
first = ring_size - rp
outdata[:first, channel_idx] = ring[rp:]
outdata[first:frames, channel_idx] = ring[:frames - first]
state["read_pos"] = (rp + frames) % ring_size
state["avail"] -= frames
self._monitor_write = write_audio
self._monitor_stream = sd.OutputStream(
device=self.monitor_device,
samplerate=device_sr,
channels=num_channels,
dtype=np.float32,
blocksize=1024,
callback=callback,
)
self._monitor_stream.start()
print(f"[Audio] Mic monitor started (device {self.monitor_device} ch {self.monitor_channel} @ {device_sr}Hz)")
def _stop_monitor(self):
"""Stop mic monitor stream"""
if self._monitor_stream:
self._monitor_stream.stop()
self._monitor_stream.close()
self._monitor_stream = None
self._monitor_write = None
print("[Audio] Mic monitor stopped")
# --- Music Playback ---
def load_music(self, file_path: str) -> bool:
@@ -981,9 +1082,13 @@ class AudioService:
device_sr = int(device_info['default_samplerate'])
record_channel = min(self.input_channel, max_channels) - 1
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)
if self._monitor_write:
self._monitor_write(indata[:, record_channel].copy())
self._stem_mic_stream = sd.InputStream(
device=self.input_device,
@@ -1003,6 +1108,7 @@ class AudioService:
self._stem_mic_stream.close()
self._stem_mic_stream = None
print("[StemRecorder] Host mic capture stopped")
self._stop_monitor()
# Global instance

View File

@@ -13,10 +13,8 @@ def get_whisper_model() -> WhisperModel:
"""Get or create Whisper model instance"""
global _whisper_model
if _whisper_model is None:
print("Loading Whisper tiny model for fast transcription...")
# Use tiny model for speed - about 3-4x faster than base
# beam_size=1 and best_of=1 for fastest inference
_whisper_model = WhisperModel("tiny", device="cpu", compute_type="int8")
print("Loading Whisper base model...")
_whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
print("Whisper model loaded")
return _whisper_model
@@ -100,13 +98,13 @@ async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None) ->
else:
audio_16k = audio
# Transcribe with speed optimizations
# Transcribe
segments, info = model.transcribe(
audio_16k,
beam_size=1, # Faster, slightly less accurate
best_of=1,
language="en", # Skip language detection
vad_filter=True, # Skip silence
beam_size=3,
language="en",
vad_filter=True,
initial_prompt="Luke at the Roost, a late-night radio talk show. The host Luke talks to callers about life, relationships, sports, politics, and pop culture.",
)
segments_list = list(segments)
text = " ".join([s.text for s in segments_list]).strip()

View File

@@ -1,249 +1,5 @@
{
"regulars": [
{
"id": "dc4916a7",
"name": "Leon",
"gender": "male",
"age": 56,
"job": "and last week his daughter asked him why he never went back to school for programming like he always talked about\u2014she found his old acceptance letter from UNM's CS program tucked",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Leon, a 63-year-old tow truck driver, called in feeling regretful after pulling a young remote worker's Tesla from a ditch, which reminded him of the computer science acceptance letter he never acted on in 1996 when his girlfriend got pregnant. The conversation became emotional as Leon realized he's the same age his father was when he died, and the host challenged him to stop making excuses and finally pursue the tech career he's been thinking about for decades instead of just \"wondering what could have been.\"",
"timestamp": 1770693549.697355
},
{
"summary": "Leon called back to share that he reached out to UNM about their computer science program and is now deciding between an online bootcamp (which he and his wife Amber can afford without loans) versus a full degree program, ultimately leaning toward the bootcamp since he struggles with self-teaching. He expressed nervousness but appreciation for his daughter holding him accountable, and emotionally shared that buying his reliable used Subaru five years ago changed his life by giving him confidence and reducing stress at his towing job.",
"timestamp": 1770951992.186027
},
{
"summary": "In this brief clip, the host begins to set up a game with caller Vence, starting to explain the rules before the audio cuts off. There's no substantive conversation or emotional content to summarize.",
"timestamp": 1771119313.497329
},
{
"summary": "Leon called in to play a dating profile game but revealed he's struggling with his coding bootcamp because he's more interested in studying poker strategy than Python. The host encouraged him that at 56, he could pursue becoming a poker pro just as much as anything else, which seemed to resonate with Leon emotionally as he realized poker is what he actually wants to do rather than what he thinks he should do.",
"timestamp": 1771119607.065818
}
],
"last_call": 1771119607.065818,
"created_at": 1770693549.697355,
"voice": "CwhRBWXzGAHq8TQ4Fs17"
},
{
"id": "584767e8",
"name": "Carl",
"gender": "male",
"age": 36,
"job": "is a firefighter",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Carl, a firefighter from Lordsburg, New Mexico, called to confess his 20-year gambling addiction, which began with casual poker games at the station and escalated to frequent casino visits and online sessions, draining his finances and leaving him with overdue bills and the fear of losing his home. Emotionally raw, he admitted the habit's destructive hold\u2014like an unquenchable fire\u2014and his pride in avoiding help, but agreed to consider support groups and an 800 hotline after the host suggested productive alternatives like gym workouts or extra volunteer shifts.",
"timestamp": 1770522170.1887732
},
{
"summary": "Here is a 1-2 sentence summary of the radio call:\n\nThe caller, Carl, discusses his progress in overcoming his gambling addiction, including rewatching The Sopranos, but the host, Luke, disagrees with Carl's high opinion of the show's ending, leading to a back-and-forth debate between the two about the merits and predictability of the Sopranos finale.",
"timestamp": 1770573289.82847
},
{
"summary": "Carl, a firefighter, called to discuss finding $15-20,000 in cash at a house fire and struggling with the temptation to keep it despite doing the right thing by returning it to the family. He's been gambling-free for three months but is financially struggling, and though he returned the money, he's been losing sleep for three nights obsessing over what he could have done with it and fearing he might have blown it at a casino anyway.",
"timestamp": 1770694065.5629818
}
],
"last_call": 1770694065.5629828,
"created_at": 1770522170.1887732,
"voice": "SOYHLrjzK2X1ezoPC6cr"
},
{
"id": "04b1a69c",
"name": "Reggie",
"gender": "male",
"age": 51,
"job": "a 39-year-old food truck operator, is reeling from a troubling discovery this morning",
"location": "in unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Reggie called in worried because his partner suddenly packed a bag and left for her mom's house without explanation and won't answer his calls, making him fear something is wrong with their relationship. The host advised him to stop calling repeatedly and have a calm conversation with her when she's ready to talk, reassuring him he's likely overreacting.",
"timestamp": 1770769705.511872
}
],
"last_call": 1770769705.511872,
"created_at": 1770769705.511872,
"voice": "N2lVS1w4EtoT3dr4eOWO"
},
{
"id": "747c6464",
"name": "Brenda",
"gender": "female",
"age": 44,
"job": "a 41-year-old ambulance driver, is fed up with the tipping culture",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Brenda called in to vent about being frustrated with automatic tipping at a diner, where a 20% tip was already added to her bill but the card reader prompted her to add an additional 25-35% while the waitress watched. She expressed feeling pressured and annoyed as an ambulance driver with two kids, struggling with whether to look cheap by reducing the tip, before playing a quick real-or-fake news game with the host.",
"timestamp": 1770770008.684104
},
{
"summary": "Brenda called in still thinking about whether a waitress remembered her tipping situation from two weeks ago, admitting she cares too much about what strangers think of her. The conversation revealed she's been avoiding dating entirely while working long shifts and dealing with family obligations, acknowledging she obsesses over small social interactions instead of actually putting herself out there romantically.",
"timestamp": 1771120062.169228
}
],
"last_call": 1771120062.169229,
"created_at": 1770770008.684105,
"voice": "hpp4J3VqNfWAUOO0d1Us"
},
{
"id": "add59d4a",
"name": "Rick",
"gender": "male",
"age": 65,
"job": "south of Silver City",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Rick called in to play \"real news or fake news\" and correctly identified a headline about a geothermal plant sale. He then shared that he's troubled about an elderly bank customer who withdrew $8,000 cash while appearing scared and mentioning his daughter's boyfriend was pressuring him about finances\u2014Rick processed the withdrawal but later learned he should have flagged it as potential elder exploitation, and he's feeling guilty about not intervening.",
"timestamp": 1770771655.536344
},
{
"summary": "Rick, a 65-year-old caller, is asked to evaluate a dating profile for 29-year-old Angela, a \"girl mom\" and MLM skin care seller with strong Christian values. He quickly passes due to the extreme age gap and her intense focus on recruiting for her \"not a pyramid scheme\" business, though he says he'd reconsider if she toned down the sales pitch and religious intensity.",
"timestamp": 1771126337.585641
}
],
"last_call": 1771126337.585642,
"created_at": 1770771655.536344,
"voice": "TX3LPaxmHKxFdv7VOQHJ"
},
{
"id": "13ff1736",
"name": "Jasmine",
"gender": "female",
"age": 36,
"job": "a 61-year-old woman who runs a small bakery in the rural Southwest, finds herself at a crossroads",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Jasmine called in to defend an earlier caller (Rick) whom she felt the host was too hard on, explaining she's been feeling guilty herself lately. She emotionally revealed that she chose her 1972 Ford Bronco restoration project over her marriage when given an ultimatum, and now regrets sleeping in the guest room with Valentine's Day approaching.",
"timestamp": 1770772286.1733272
},
{
"summary": "Jasmine called to update Luke about her relationship with David after previously discussing their issues over her Ford Bronco obsession. David invited her to watch a SpaceX launch together before Valentine's Day, but she's anxious it will be awkward since they've barely talked in weeks, though Luke convinces her to just enjoy the moment together without forcing conversation.",
"timestamp": 1771033676.7729769
}
],
"last_call": 1771033676.7729769,
"created_at": 1770772286.1733272,
"voice": "pFZP5JQG7iQjIQuC4Bku"
},
{
"id": "f21d1346",
"name": "Andre",
"gender": "male",
"age": 54,
"job": "is a firefighter unknown",
"location": "in unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Andre called into a radio game show but first shared that he's upset about being named in court documents related to a lawsuit involving a family he helped in December by returning $15,000 after a house fire. Though the host reassured him he has nothing to worry about since he did the right thing, Andre expressed frustration that his good deed led to him being dragged into an insurance dispute.",
"timestamp": 1770770944.7940538
},
{
"summary": "Andre calls back with an update: the lawsuit against him was dropped, and the family he helped sent him a card with $500 cash, which makes him feel conflicted about accepting payment for doing the right thing. On a positive note, he's been gambling-free for two months and attending meetings, and Luke encourages him to keep the money or donate it, celebrating his progress.",
"timestamp": 1770870907.493257
}
],
"last_call": 1770870907.493258,
"created_at": 1770770944.7940538,
"voice": "JBFqnCBsd6RMkjVDRZzb"
},
{
"id": "d97cb6f9",
"name": "Carla",
"gender": "female",
"age": 26,
"job": "is a vet tech",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Carla, separated from her husband but not yet divorced, vented about her intrusive in-laws who relentlessly call and dictate her life\u2014from finances and household matters to her clothing choices\u2014while her spineless spouse relays their demands, making her feel trapped in a one-sided war. With her own parents unavailable (father deceased, mother distant), she leans on her bickering but honest sister for support, underscoring her deep frustration and sense of isolation.",
"timestamp": 1770522530.8554251
},
{
"summary": "Carla dismissed celebrity science theories like Terrence Howard's after watching Neil deGrasse Tyson's critique, then marveled at JWST's exoplanet discoveries before sharing her relief at finally cutting off her toxic in-laws amid her ongoing divorce. She expressed deep heartbreak over actor James Ransone's suicide at 46, reflecting on life's fragility, her late father's death, and the need to eliminate family drama, leaving her contemplative and planning a solo desert drive for clarity.",
"timestamp": 1770526316.004708
},
{
"summary": "In this call, Carla discovered some explicit photos of her ex-husband and his old girlfriend in a box of his old ham radio equipment. She is feeling very uncomfortable about the situation and is seeking advice from the radio host, Luke, on how to best handle and dispose of the photos.",
"timestamp": 1770602323.234795
},
{
"summary": "Carla called with an update about burning the explicit photos of her ex-husband and his old girlfriend, revealing that the girlfriend unexpectedly messaged her on Facebook to \"clear the air\" after apparently hearing about the situation through Carla's previous radio call. When Luke asked about her most embarrassing masturbation material, Carla admitted to using historical romance novels during her failing marriage, explaining she was drawn to the fantasy of men who actually cared and paid attention, unlike her ex-husband who ignored her to play video games.",
"timestamp": 1770871317.049056
},
{
"summary": "Okay, here's a 1-2 sentence summary of the radio call:\n\nThe caller, Carla, was asked to give her honest opinion on a dating profile for a man named Todd. After reviewing the profile, Carla politely declined, explaining that the profile seemed a bit \"try-hard\" for her tastes, and outlined the qualities she would prefer in a potential date, such as a good sense of humor and an adventurous spirit. The host acknowledged that Carla was not interested in dating Todd.",
"timestamp": 1771121545.873672
}
],
"last_call": 1771121545.873673,
"created_at": 1770522530.855426,
"voice": "FGY2WhTYpPnrIDTdsKH5"
},
{
"id": "7be7317c",
"name": "Jerome",
"gender": "male",
"age": 53,
"job": "phone",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Jerome, a police officer in Texas, called from a DQ parking lot worried about AI writing police reports after his son sent him an article suggesting it might replace him. Through the conversation, he moved from fear about accountability and accuracy in criminal cases to acknowledging that AI handling routine paperwork (like cattle complaints) could free him up to do more meaningful police work in his understaffed county, though he remains uncertain about where this technology will lead.",
"timestamp": 1770692087.560522
},
{
"summary": "The caller described a turbulent couple of weeks, mentioning an issue with AI writing police reports, which he suggested was just the beginning of a larger problem. He seemed concerned about the developments and wanted to discuss the topic further with the host.",
"timestamp": 1770892192.893108
}
],
"last_call": 1770892192.89311,
"created_at": 1770692087.560523,
"voice": "IKne3meq5aSn9XLyUdCD"
},
{
"id": "f383d29b",
"name": "Megan",
"gender": "female",
"age": 34,
"job": "which got her thinking about her sister Crystal up in Flagstaff who hasn't seen a truly dark sky",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Megan, a kindergarten teacher from the bootheel, called in after one of her students asked if stars know we're looking at them, which led her to reflect on how her sister Crystal in Flagstaff has stopped appreciating the night sky despite having access to it. The conversation took an unexpected turn when Luke challenged her to admit a gross habit, and after some prodding, she confessed to picking dry skin off her feet while watching TV and flicking it on the floor.",
"timestamp": 1770870641.723117
},
{
"summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, Megan, is following up on a previous call about her sister Crystal, who lives in Flagstaff and has lost appreciation for the night sky. Megan seems eager to provide an update on the situation with her sister.",
"timestamp": 1770894505.175125
},
{
"summary": "In summary, the caller presented a dating profile for a 63-year-old man named Frank who loves making birdhouses. The host, Megan, gave her honest assessment - she appreciated some aspects of Frank's profile, like his openness about his situation, but had reservations about his intense birdhouse obsession. Megan seemed unsure if they would be a good match, despite the host's attempts to get her to consider dating Frank under different hypothetical circumstances. The conversation focused on Megan's reaction to Frank's profile and her hesitation about pursuing a relationship with him.",
"timestamp": 1771122973.966489
}
],
"last_call": 1771122973.96649,
"created_at": 1770870641.723117,
"voice": "cgSgspJ2msm6clMCkdW9"
},
{
"id": "49147bd5",
"name": "Keith",
@@ -291,6 +47,234 @@
],
"last_call": 1770951226.534601,
"created_at": 1770951226.534601
},
{
"id": "13ff1736",
"name": "Jasmine",
"gender": "female",
"age": 36,
"job": "a 61-year-old woman who runs a small bakery in the rural Southwest, finds herself at a crossroads",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Jasmine called in to defend an earlier caller (Rick) whom she felt the host was too hard on, explaining she's been feeling guilty herself lately. She emotionally revealed that she chose her 1972 Ford Bronco restoration project over her marriage when given an ultimatum, and now regrets sleeping in the guest room with Valentine's Day approaching.",
"timestamp": 1770772286.1733272
},
{
"summary": "Jasmine called to update Luke about her relationship with David after previously discussing their issues over her Ford Bronco obsession. David invited her to watch a SpaceX launch together before Valentine's Day, but she's anxious it will be awkward since they've barely talked in weeks, though Luke convinces her to just enjoy the moment together without forcing conversation.",
"timestamp": 1771033676.7729769
}
],
"last_call": 1771033676.7729769,
"created_at": 1770772286.1733272,
"voice": "pFZP5JQG7iQjIQuC4Bku"
},
{
"id": "dc4916a7",
"name": "Leon",
"gender": "male",
"age": 56,
"job": "and last week his daughter asked him why he never went back to school for programming like he always talked about\u2014she found his old acceptance letter from UNM's CS program tucked",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Leon, a 63-year-old tow truck driver, called in feeling regretful after pulling a young remote worker's Tesla from a ditch, which reminded him of the computer science acceptance letter he never acted on in 1996 when his girlfriend got pregnant. The conversation became emotional as Leon realized he's the same age his father was when he died, and the host challenged him to stop making excuses and finally pursue the tech career he's been thinking about for decades instead of just \"wondering what could have been.\"",
"timestamp": 1770693549.697355
},
{
"summary": "Leon called back to share that he reached out to UNM about their computer science program and is now deciding between an online bootcamp (which he and his wife Amber can afford without loans) versus a full degree program, ultimately leaning toward the bootcamp since he struggles with self-teaching. He expressed nervousness but appreciation for his daughter holding him accountable, and emotionally shared that buying his reliable used Subaru five years ago changed his life by giving him confidence and reducing stress at his towing job.",
"timestamp": 1770951992.186027
},
{
"summary": "In this brief clip, the host begins to set up a game with caller Vence, starting to explain the rules before the audio cuts off. There's no substantive conversation or emotional content to summarize.",
"timestamp": 1771119313.497329
},
{
"summary": "Leon called in to play a dating profile game but revealed he's struggling with his coding bootcamp because he's more interested in studying poker strategy than Python. The host encouraged him that at 56, he could pursue becoming a poker pro just as much as anything else, which seemed to resonate with Leon emotionally as he realized poker is what he actually wants to do rather than what he thinks he should do.",
"timestamp": 1771119607.065818
}
],
"last_call": 1771119607.065818,
"created_at": 1770693549.697355,
"voice": "CwhRBWXzGAHq8TQ4Fs17"
},
{
"id": "747c6464",
"name": "Brenda",
"gender": "female",
"age": 44,
"job": "a 41-year-old ambulance driver, is fed up with the tipping culture",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Brenda called in to vent about being frustrated with automatic tipping at a diner, where a 20% tip was already added to her bill but the card reader prompted her to add an additional 25-35% while the waitress watched. She expressed feeling pressured and annoyed as an ambulance driver with two kids, struggling with whether to look cheap by reducing the tip, before playing a quick real-or-fake news game with the host.",
"timestamp": 1770770008.684104
},
{
"summary": "Brenda called in still thinking about whether a waitress remembered her tipping situation from two weeks ago, admitting she cares too much about what strangers think of her. The conversation revealed she's been avoiding dating entirely while working long shifts and dealing with family obligations, acknowledging she obsesses over small social interactions instead of actually putting herself out there romantically.",
"timestamp": 1771120062.169228
}
],
"last_call": 1771120062.169229,
"created_at": 1770770008.684105,
"voice": "hpp4J3VqNfWAUOO0d1Us"
},
{
"id": "d97cb6f9",
"name": "Carla",
"gender": "female",
"age": 26,
"job": "is a vet tech",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Carla, separated from her husband but not yet divorced, vented about her intrusive in-laws who relentlessly call and dictate her life\u2014from finances and household matters to her clothing choices\u2014while her spineless spouse relays their demands, making her feel trapped in a one-sided war. With her own parents unavailable (father deceased, mother distant), she leans on her bickering but honest sister for support, underscoring her deep frustration and sense of isolation.",
"timestamp": 1770522530.8554251
},
{
"summary": "Carla dismissed celebrity science theories like Terrence Howard's after watching Neil deGrasse Tyson's critique, then marveled at JWST's exoplanet discoveries before sharing her relief at finally cutting off her toxic in-laws amid her ongoing divorce. She expressed deep heartbreak over actor James Ransone's suicide at 46, reflecting on life's fragility, her late father's death, and the need to eliminate family drama, leaving her contemplative and planning a solo desert drive for clarity.",
"timestamp": 1770526316.004708
},
{
"summary": "In this call, Carla discovered some explicit photos of her ex-husband and his old girlfriend in a box of his old ham radio equipment. She is feeling very uncomfortable about the situation and is seeking advice from the radio host, Luke, on how to best handle and dispose of the photos.",
"timestamp": 1770602323.234795
},
{
"summary": "Carla called with an update about burning the explicit photos of her ex-husband and his old girlfriend, revealing that the girlfriend unexpectedly messaged her on Facebook to \"clear the air\" after apparently hearing about the situation through Carla's previous radio call. When Luke asked about her most embarrassing masturbation material, Carla admitted to using historical romance novels during her failing marriage, explaining she was drawn to the fantasy of men who actually cared and paid attention, unlike her ex-husband who ignored her to play video games.",
"timestamp": 1770871317.049056
},
{
"summary": "Okay, here's a 1-2 sentence summary of the radio call:\n\nThe caller, Carla, was asked to give her honest opinion on a dating profile for a man named Todd. After reviewing the profile, Carla politely declined, explaining that the profile seemed a bit \"try-hard\" for her tastes, and outlined the qualities she would prefer in a potential date, such as a good sense of humor and an adventurous spirit. The host acknowledged that Carla was not interested in dating Todd.",
"timestamp": 1771121545.873672
}
],
"last_call": 1771121545.873673,
"created_at": 1770522530.855426,
"voice": "FGY2WhTYpPnrIDTdsKH5"
},
{
"id": "f383d29b",
"name": "Megan",
"gender": "female",
"age": 34,
"job": "which got her thinking about her sister Crystal up in Flagstaff who hasn't seen a truly dark sky",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Megan, a kindergarten teacher from the bootheel, called in after one of her students asked if stars know we're looking at them, which led her to reflect on how her sister Crystal in Flagstaff has stopped appreciating the night sky despite having access to it. The conversation took an unexpected turn when Luke challenged her to admit a gross habit, and after some prodding, she confessed to picking dry skin off her feet while watching TV and flicking it on the floor.",
"timestamp": 1770870641.723117
},
{
"summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, Megan, is following up on a previous call about her sister Crystal, who lives in Flagstaff and has lost appreciation for the night sky. Megan seems eager to provide an update on the situation with her sister.",
"timestamp": 1770894505.175125
},
{
"summary": "In summary, the caller presented a dating profile for a 63-year-old man named Frank who loves making birdhouses. The host, Megan, gave her honest assessment - she appreciated some aspects of Frank's profile, like his openness about his situation, but had reservations about his intense birdhouse obsession. Megan seemed unsure if they would be a good match, despite the host's attempts to get her to consider dating Frank under different hypothetical circumstances. The conversation focused on Megan's reaction to Frank's profile and her hesitation about pursuing a relationship with him.",
"timestamp": 1771122973.966489
}
],
"last_call": 1771122973.96649,
"created_at": 1770870641.723117,
"voice": "cgSgspJ2msm6clMCkdW9"
},
{
"id": "add59d4a",
"name": "Rick",
"gender": "male",
"age": 65,
"job": "south of Silver City",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Rick called in to play \"real news or fake news\" and correctly identified a headline about a geothermal plant sale. He then shared that he's troubled about an elderly bank customer who withdrew $8,000 cash while appearing scared and mentioning his daughter's boyfriend was pressuring him about finances\u2014Rick processed the withdrawal but later learned he should have flagged it as potential elder exploitation, and he's feeling guilty about not intervening.",
"timestamp": 1770771655.536344
},
{
"summary": "Rick, a 65-year-old caller, is asked to evaluate a dating profile for 29-year-old Angela, a \"girl mom\" and MLM skin care seller with strong Christian values. He quickly passes due to the extreme age gap and her intense focus on recruiting for her \"not a pyramid scheme\" business, though he says he'd reconsider if she toned down the sales pitch and religious intensity.",
"timestamp": 1771126337.585641
}
],
"last_call": 1771126337.585642,
"created_at": 1770771655.536344,
"voice": "TX3LPaxmHKxFdv7VOQHJ"
},
{
"id": "74ac2916",
"name": "Benny",
"gender": "male",
"age": 31,
"job": "engine off but heater still ticking as it cools, staring at the glow from the kitchen window where his wife is probably still going through those bank statements she found",
"location": "unknown",
"personality_traits": [],
"voice": "Clive",
"call_history": [
{
"summary": "Benny has been secretly sending money to his daughter for six months, leading his wife to suspect gambling or an affair when she found bank statements, and he's now afraid to go inside and explain. He assumed his daughter lost her job based on changed texting patterns, but the host points out he doesn't actually know why she needs money and emphasizes he needs to have honest conversations with both his wife and daughter about what's really going on.",
"timestamp": 1771214146.346665
}
],
"last_call": 1771214146.346665,
"created_at": 1771214146.346665
},
{
"id": "2768e2ac",
"name": "Rochelle",
"gender": "female",
"age": 55,
"job": "and when she opened the fitness app they used to share\u2014back when they were doing that couples' couch-to-5K thing\u2014she realized he's been watching her movements for the eight months since the divorce was finalized",
"location": "in unknown",
"personality_traits": [],
"voice": "Sarah",
"call_history": [
{
"summary": "Rochelle called in after spending three hours tracking her ex-husband's location on a fitness app they still share, watching him circle her neighborhood and park at a nearby Sonic for 40 minutes while sending cryptic texts. Though she initiated their divorce eight months ago, she admitted she might be glad he's \"creeping\" on her and got emotional realizing they both still have access to each other's locations\u2014and that she might still miss him despite dating someone new.",
"timestamp": 1771217728.036122
}
],
"last_call": 1771217728.0361228,
"created_at": 1771217728.0361228
},
{
"id": "dec4f7c9",
"name": "Terri",
"gender": "female",
"age": 38,
"job": "feet up on the desk, laughing that hollow laugh she does when things aren't funny at all",
"location": "in unknown",
"personality_traits": [],
"voice": "Olivia",
"call_history": [
{
"summary": "Terry called in devastated that her best friend of 30 years, Michelle, has been secretly sleeping with Terry's ex-husband since their divorce six months ago\u2014and worse, Michelle comforted Terry about seeing David with \"someone\" at Applebee's two weeks ago without revealing it was her. Luke advised Terry to cut off both Michelle and David and move forward, acknowledging her grief is valid but encouraging her to choose not to stay hurt, which Terry ultimately accepted while realizing she'd been mourning the idea of her marriage more than the actual relationship.",
"timestamp": 1771222133.612614
}
],
"last_call": 1771222133.612614,
"created_at": 1771222133.612614
},
{
"id": "514725e5",
"name": "Dolores",
"gender": "female",
"age": 33,
"job": "and every Saturday and Sunday she drives to open houses in Silver City, Deming, sometimes as far as Las Cruces, pretending she's",
"location": "unknown",
"personality_traits": [],
"voice": "Luna",
"call_history": [
{
"summary": "Dolores calls from a parking lot after panicking when a realtor recognized her at an open house\u2014she's been attending showings for eight months (six years total) with no intention of buying, using them to escape her routine life working at a gun range in Lordsburg. Luke tells her to stop pretending and actually take steps to change her life, which she tearfully accepts, committing to make a real plan instead of just fantasizing.",
"timestamp": 1771223091.851768
}
],
"last_call": 1771223091.851769,
"created_at": 1771223091.851769
}
]
}

10
data/voicemails.json Normal file
View File

@@ -0,0 +1,10 @@
{
"voicemails": [],
"deleted_timestamps": [
1771212705,
1771146434,
1771146564,
1771146952,
1771213151
]
}

View File

@@ -0,0 +1,505 @@
# Clip Social Media Upload Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Generate social media descriptions/hashtags for podcast clips and upload them to Instagram Reels + YouTube Shorts via Postiz API.
**Architecture:** Two changes — (1) extend `make_clips.py` to add a second LLM call that generates descriptions + hashtags, saved as `clips-metadata.json`, (2) new `upload_clips.py` script that reads that metadata and pushes clips through the self-hosted Postiz instance at `social.lukeattheroost.com`.
**Tech Stack:** Python, OpenRouter API (Claude Sonnet), Postiz REST API, requests library (already installed)
---
### Task 1: Add `generate_social_metadata()` to `make_clips.py`
**Files:**
- Modify: `make_clips.py:231-312` (after `select_clips_with_llm`)
**Step 1: Add the function after `select_clips_with_llm`**
Add this function at line ~314 (after `select_clips_with_llm` returns):
```python
def generate_social_metadata(clips: list[dict], labeled_transcript: str,
episode_number: int | None) -> list[dict]:
"""Generate social media descriptions and hashtags for each clip."""
if not OPENROUTER_API_KEY:
print("Error: OPENROUTER_API_KEY not set in .env")
sys.exit(1)
clips_summary = "\n".join(
f'{i+1}. "{c["title"]}"{c["caption_text"]}'
for i, c in enumerate(clips)
)
episode_context = f"This is Episode {episode_number} of " if episode_number else "This is an episode of "
prompt = f"""{episode_context}the "Luke at the Roost" podcast — a late-night call-in show where AI-generated callers share stories, confessions, and hot takes with host Luke.
Here are {len(clips)} clips selected from this episode:
{clips_summary}
For each clip, generate:
1. description: A short, engaging description for social media (1-2 sentences, hook the viewer, conversational tone). Do NOT include hashtags in the description.
2. hashtags: An array of 5-8 hashtags. Always include #lukeattheroost and #podcast. Add topic-relevant and trending-style tags.
Respond with ONLY a JSON array matching the clip order:
[{{"description": "...", "hashtags": ["#tag1", "#tag2", ...]}}]"""
response = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": "anthropic/claude-sonnet-4-5",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 2048,
"temperature": 0.7,
},
)
if response.status_code != 200:
print(f"Error from OpenRouter: {response.text}")
return clips
content = response.json()["choices"][0]["message"]["content"].strip()
if content.startswith("```"):
content = re.sub(r"^```(?:json)?\n?", "", content)
content = re.sub(r"\n?```$", "", content)
try:
metadata = json.loads(content)
except json.JSONDecodeError as e:
print(f"Error parsing social metadata: {e}")
return clips
for i, clip in enumerate(clips):
if i < len(metadata):
clip["description"] = metadata[i].get("description", "")
clip["hashtags"] = metadata[i].get("hashtags", [])
return clips
```
**Step 2: Run existing tests to verify no breakage**
Run: `pytest tests/ -v`
Expected: All existing tests pass (this is a new function, no side effects yet)
**Step 3: Commit**
```bash
git add make_clips.py
git commit -m "Add generate_social_metadata() for clip descriptions and hashtags"
```
---
### Task 2: Integrate metadata generation + JSON save into `main()`
**Files:**
- Modify: `make_clips.py:1082-1289` (inside `main()`)
**Step 1: Add metadata generation call and JSON save**
After the LLM clip selection step (~line 1196, after the clip summary print loop), add:
```python
# Step N: Generate social media metadata
print(f"\n[{extract_step - 1}/{step_total}] Generating social media descriptions...")
clips = generate_social_metadata(clips, labeled_transcript, episode_number)
for i, clip in enumerate(clips):
if "description" in clip:
print(f" Clip {i+1}: {clip['description'][:80]}...")
print(f" {' '.join(clip.get('hashtags', []))}")
```
Note: This needs to be inserted BEFORE the audio extraction step, and the step numbering needs to be adjusted (total steps goes from 5/6 to 6/7).
At the end of `main()`, before the summary print, save the metadata JSON:
```python
# Save clips metadata for social upload
metadata_path = output_dir / "clips-metadata.json"
metadata = []
for i, clip in enumerate(clips):
slug = slugify(clip["title"])
metadata.append({
"title": clip["title"],
"clip_file": f"clip-{i+1}-{slug}.mp4",
"audio_file": f"clip-{i+1}-{slug}.mp3",
"caption_text": clip.get("caption_text", ""),
"description": clip.get("description", ""),
"hashtags": clip.get("hashtags", []),
"start_time": clip["start_time"],
"end_time": clip["end_time"],
"duration": round(clip["end_time"] - clip["start_time"], 1),
"episode_number": episode_number,
})
with open(metadata_path, "w") as f:
json.dump(metadata, f, indent=2)
print(f"\nSocial metadata: {metadata_path}")
```
**Step 2: Adjust step numbering**
The pipeline steps need to account for the new metadata step. Update `step_total` calculation:
```python
step_total = (7 if two_pass else 6)
```
And shift the extract/video step numbers up by 1.
**Step 3: Test manually**
Run: `python make_clips.py --help`
Expected: No import errors, help displays normally
**Step 4: Commit**
```bash
git add make_clips.py
git commit -m "Save clips-metadata.json with social descriptions and hashtags"
```
---
### Task 3: Create `upload_clips.py` — core structure and Postiz API helpers
**Files:**
- Create: `upload_clips.py`
**Step 1: Write the script**
```python
#!/usr/bin/env python3
"""Upload podcast clips to Instagram Reels and YouTube Shorts via Postiz.
Usage:
python upload_clips.py clips/episode-12/
python upload_clips.py clips/episode-12/ --clip 1
python upload_clips.py clips/episode-12/ --youtube-only
python upload_clips.py clips/episode-12/ --instagram-only
python upload_clips.py clips/episode-12/ --schedule "2026-02-16T10:00:00"
python upload_clips.py clips/episode-12/ --yes # skip confirmation
"""
import argparse
import json
import sys
from pathlib import Path
import requests
from dotenv import load_dotenv
import os
load_dotenv(Path(__file__).parent / ".env")
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
def get_api_url(path: str) -> str:
"""Build full Postiz API URL."""
base = POSTIZ_URL.rstrip("/")
# Postiz self-hosted API is at /api/public/v1 when NEXT_PUBLIC_BACKEND_URL is the app URL
# but the docs say /public/v1 relative to backend URL. Try the standard path.
return f"{base}/api/public/v1{path}"
def api_headers() -> dict:
return {
"Authorization": POSTIZ_API_KEY,
"Content-Type": "application/json",
}
def fetch_integrations() -> list[dict]:
"""Fetch connected social accounts from Postiz."""
resp = requests.get(get_api_url("/integrations"), headers=api_headers(), timeout=15)
if resp.status_code != 200:
print(f"Error fetching integrations: {resp.status_code} {resp.text[:200]}")
sys.exit(1)
return resp.json()
def find_integration(integrations: list[dict], provider: str) -> dict | None:
"""Find integration by provider name (e.g. 'instagram', 'youtube')."""
for integ in integrations:
if integ.get("providerIdentifier", "").startswith(provider):
return integ
if integ.get("provider", "").startswith(provider):
return integ
return None
def upload_file(file_path: Path) -> dict:
"""Upload a file to Postiz. Returns {id, path}."""
headers = {"Authorization": POSTIZ_API_KEY}
with open(file_path, "rb") as f:
resp = requests.post(
get_api_url("/upload"),
headers=headers,
files={"file": (file_path.name, f, "video/mp4")},
timeout=120,
)
if resp.status_code != 200:
print(f"Upload failed: {resp.status_code} {resp.text[:200]}")
return {}
return resp.json()
def create_post(integration_id: str, content: str, media: dict,
settings: dict, schedule: str | None = None) -> dict:
"""Create a post on Postiz."""
post_type = "schedule" if schedule else "now"
payload = {
"type": post_type,
"posts": [
{
"integration": {"id": integration_id},
"value": [
{
"content": content,
"image": [media] if media else [],
}
],
"settings": settings,
}
],
}
if schedule:
payload["date"] = schedule
resp = requests.post(
get_api_url("/posts"),
headers=api_headers(),
json=payload,
timeout=30,
)
if resp.status_code not in (200, 201):
print(f"Post creation failed: {resp.status_code} {resp.text[:300]}")
return {}
return resp.json()
def build_instagram_content(clip: dict) -> str:
"""Build Instagram post content: description + hashtags."""
parts = [clip.get("description", clip.get("caption_text", ""))]
hashtags = clip.get("hashtags", [])
if hashtags:
parts.append("\n\n" + " ".join(hashtags))
return "".join(parts)
def build_youtube_content(clip: dict) -> str:
"""Build YouTube description."""
parts = [clip.get("description", clip.get("caption_text", ""))]
hashtags = clip.get("hashtags", [])
if hashtags:
parts.append("\n\n" + " ".join(hashtags))
parts.append("\n\nListen to the full episode: lukeattheroost.com")
return "".join(parts)
def main():
parser = argparse.ArgumentParser(description="Upload podcast clips to social media via Postiz")
parser.add_argument("clips_dir", help="Path to clips directory (e.g. clips/episode-12/)")
parser.add_argument("--clip", "-c", type=int, help="Upload only clip N (1-indexed)")
parser.add_argument("--instagram-only", action="store_true", help="Upload to Instagram only")
parser.add_argument("--youtube-only", action="store_true", help="Upload to YouTube only")
parser.add_argument("--schedule", "-s", help="Schedule time (ISO 8601, e.g. 2026-02-16T10:00:00)")
parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
parser.add_argument("--dry-run", action="store_true", help="Show what would be uploaded without posting")
args = parser.parse_args()
if not POSTIZ_API_KEY:
print("Error: POSTIZ_API_KEY not set in .env")
sys.exit(1)
clips_dir = Path(args.clips_dir).expanduser().resolve()
metadata_path = clips_dir / "clips-metadata.json"
if not metadata_path.exists():
print(f"Error: No clips-metadata.json found in {clips_dir}")
print("Run make_clips.py first to generate clips and metadata.")
sys.exit(1)
with open(metadata_path) as f:
clips = json.load(f)
if args.clip:
if args.clip < 1 or args.clip > len(clips):
print(f"Error: Clip {args.clip} not found (have {len(clips)} clips)")
sys.exit(1)
clips = [clips[args.clip - 1]]
# Determine which platforms to post to
do_instagram = not args.youtube_only
do_youtube = not args.instagram_only
# Fetch integrations from Postiz
print("Fetching connected accounts from Postiz...")
integrations = fetch_integrations()
ig_integration = None
yt_integration = None
if do_instagram:
ig_integration = find_integration(integrations, "instagram")
if not ig_integration:
print("Warning: No Instagram account connected in Postiz")
do_instagram = False
if do_youtube:
yt_integration = find_integration(integrations, "youtube")
if not yt_integration:
print("Warning: No YouTube account connected in Postiz")
do_youtube = False
if not do_instagram and not do_youtube:
print("Error: No platforms available to upload to")
sys.exit(1)
# Show summary
platforms = []
if do_instagram:
platforms.append(f"Instagram Reels ({ig_integration.get('name', 'connected')})")
if do_youtube:
platforms.append(f"YouTube Shorts ({yt_integration.get('name', 'connected')})")
print(f"\nUploading {len(clips)} clip(s) to: {', '.join(platforms)}")
if args.schedule:
print(f"Scheduled for: {args.schedule}")
print()
for i, clip in enumerate(clips):
print(f" {i+1}. \"{clip['title']}\" ({clip['duration']:.0f}s)")
print(f" {clip.get('description', '')[:80]}")
print(f" {' '.join(clip.get('hashtags', []))}")
print()
if args.dry_run:
print("Dry run — nothing uploaded.")
return
if not args.yes:
confirm = input("Proceed? [y/N] ").strip().lower()
if confirm != "y":
print("Cancelled.")
return
# Upload each clip
for i, clip in enumerate(clips):
clip_file = clips_dir / clip["clip_file"]
if not clip_file.exists():
print(f" Clip {i+1}: Video file not found: {clip_file}")
continue
print(f"\n Clip {i+1}: \"{clip['title']}\"")
# Upload video to Postiz
print(f" Uploading {clip_file.name}...")
media = upload_file(clip_file)
if not media:
print(f" Failed to upload video, skipping")
continue
print(f" Uploaded: {media.get('path', 'ok')}")
# Post to Instagram Reels
if do_instagram:
print(f" Posting to Instagram Reels...")
content = build_instagram_content(clip)
settings = {
"__type": "instagram",
"post_type": "reel",
}
result = create_post(
ig_integration["id"], content, media, settings, args.schedule
)
if result:
print(f" Instagram: Posted!")
else:
print(f" Instagram: Failed")
# Post to YouTube Shorts
if do_youtube:
print(f" Posting to YouTube Shorts...")
content = build_youtube_content(clip)
settings = {
"__type": "youtube",
"title": clip["title"],
"type": "short",
"selfDeclaredMadeForKids": False,
"tags": [h.lstrip("#") for h in clip.get("hashtags", [])],
}
result = create_post(
yt_integration["id"], content, media, settings, args.schedule
)
if result:
print(f" YouTube: Posted!")
else:
print(f" YouTube: Failed")
print(f"\nDone!")
if __name__ == "__main__":
main()
```
**Step 2: Add `POSTIZ_API_KEY` and `POSTIZ_URL` to `.env`**
Add to `.env`:
```
POSTIZ_API_KEY=your-postiz-api-key-here
POSTIZ_URL=https://social.lukeattheroost.com
```
Get your API key from Postiz Settings page.
**Step 3: Test the script loads**
Run: `python upload_clips.py --help`
Expected: Help text displays with all flags
**Step 4: Commit**
```bash
git add upload_clips.py
git commit -m "Add upload_clips.py for posting clips to Instagram/YouTube via Postiz"
```
---
### Task 4: Test with real Postiz instance
**Step 1: Get Postiz API key**
Go to `https://social.lukeattheroost.com` → Settings → API Keys → Generate key. Add to `.env` as `POSTIZ_API_KEY`.
**Step 2: Verify integrations endpoint**
Run: `python -c "from upload_clips import *; print(json.dumps(fetch_integrations(), indent=2))"`
This confirms the API key works and shows connected Instagram/YouTube accounts. Note the integration IDs and provider identifiers — if `find_integration()` doesn't match correctly, adjust the provider string matching.
**Step 3: Dry-run with existing clips**
Run: `python upload_clips.py clips/episode-12/ --dry-run`
Expected: Shows clip summary, "Dry run — nothing uploaded."
**Step 4: Upload a single test clip**
Run: `python upload_clips.py clips/episode-12/ --clip 1 --instagram-only`
Check Postiz dashboard and Instagram to verify it posted as a Reel.
**Step 5: Commit .env update (do NOT commit the key itself)**
The `.env` is gitignored so no action needed. Just ensure the key names are documented in CLAUDE.md if desired.

View File

@@ -349,6 +349,19 @@ section h2 {
margin-bottom: 10px;
}
.music-section select optgroup {
color: var(--accent);
font-weight: bold;
font-style: normal;
padding: 4px 0;
}
.music-section select option {
color: var(--text);
font-weight: normal;
padding: 2px 8px;
}
.music-controls {
display: flex;
gap: 8px;
@@ -725,3 +738,26 @@ section h2 {
.message.real-caller { border-left: 3px solid var(--accent-red); padding-left: 0.5rem; }
.message.ai-caller { border-left: 3px solid var(--accent); padding-left: 0.5rem; }
.message.host { border-left: 3px solid var(--accent-green); padding-left: 0.5rem; }
/* Voicemail */
.voicemail-section { margin: 1rem 0; }
.voicemail-list { border: 1px solid rgba(232, 121, 29, 0.15); border-radius: var(--radius-sm); padding: 0.5rem; max-height: 200px; overflow-y: auto; }
.voicemail-badge { background: var(--accent-red); color: white; font-size: 0.7rem; font-weight: bold; padding: 0.1rem 0.45rem; border-radius: 10px; margin-left: 0.4rem; vertical-align: middle; }
.voicemail-badge.hidden { display: none; }
.vm-item { display: flex; align-items: center; justify-content: space-between; padding: 0.4rem 0.5rem; border-bottom: 1px solid rgba(232, 121, 29, 0.08); }
.vm-item:last-child { border-bottom: none; }
.vm-item.vm-unlistened { background: rgba(232, 121, 29, 0.06); }
.vm-info { display: flex; gap: 0.6rem; align-items: center; flex: 1; min-width: 0; }
.vm-phone { font-family: monospace; color: var(--accent); font-size: 0.85rem; }
.vm-time { color: var(--text-muted); font-size: 0.8rem; }
.vm-dur { color: var(--text-muted); font-size: 0.8rem; }
.vm-actions { display: flex; gap: 0.3rem; flex-shrink: 0; }
.vm-btn { border: none; padding: 0.2rem 0.5rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.75rem; transition: background 0.2s; }
.vm-btn.listen { background: var(--accent); color: white; }
.vm-btn.listen:hover { background: var(--accent-hover); }
.vm-btn.on-air { background: var(--accent-green); color: white; }
.vm-btn.on-air:hover { background: #6a9a4c; }
.vm-btn.save { background: #3a7bd5; color: white; }
.vm-btn.save:hover { background: #2a5db0; }
.vm-btn.delete { background: var(--accent-red); color: white; }
.vm-btn.delete:hover { background: #e03030; }

View File

@@ -65,6 +65,14 @@
</div>
</section>
<!-- Voicemail -->
<section class="voicemail-section">
<h2>Voicemail <span id="voicemail-badge" class="voicemail-badge hidden">0</span></h2>
<div id="voicemail-list" class="voicemail-list">
<div class="queue-empty">No voicemails</div>
</div>
</section>
<!-- Chat -->
<section class="chat-section">
<div id="chat" class="chat-log"></div>
@@ -224,6 +232,6 @@
</div>
</div>
<script src="/js/app.js?v=15"></script>
<script src="/js/app.js?v=17"></script>
</body>
</html>

View File

@@ -16,6 +16,15 @@ let tracks = [];
let sounds = [];
// --- Helpers ---
function _isTyping() {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
// --- Safe JSON parsing ---
async function safeFetch(url, options = {}, timeoutMs = 30000) {
const controller = new AbortController();
@@ -51,6 +60,8 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadSounds();
await loadSettings();
initEventListeners();
loadVoicemails();
setInterval(loadVoicemails, 30000);
log('Ready. Configure audio devices in Settings, then click a caller to start.');
console.log('AI Radio Show ready');
} catch (err) {
@@ -137,6 +148,20 @@ function initEventListeners() {
talkBtn.addEventListener('touchend', e => { e.preventDefault(); stopRecording(); });
}
// Spacebar push-to-talk — blur buttons so Space doesn't also trigger button click
document.addEventListener('keydown', e => {
if (e.code !== 'Space' || e.repeat || _isTyping()) return;
e.preventDefault();
// Blur any focused button so browser doesn't fire its click
if (document.activeElement?.tagName === 'BUTTON') document.activeElement.blur();
startRecording();
});
document.addEventListener('keyup', e => {
if (e.code !== 'Space' || _isTyping()) return;
e.preventDefault();
stopRecording();
});
// Type button
document.getElementById('type-btn')?.addEventListener('click', () => {
document.getElementById('type-modal')?.classList.remove('hidden');
@@ -630,11 +655,31 @@ async function loadMusic() {
const previousValue = select.value;
select.innerHTML = '';
tracks.forEach((track, i) => {
const option = document.createElement('option');
option.value = track.file;
option.textContent = track.name;
select.appendChild(option);
// Group tracks by genre
const genres = {};
tracks.forEach(track => {
const genre = track.genre || 'Other';
if (!genres[genre]) genres[genre] = [];
genres[genre].push(track);
});
// Sort genre names, but put "Other" last
const genreOrder = Object.keys(genres).sort((a, b) => {
if (a === 'Other') return 1;
if (b === 'Other') return -1;
return a.localeCompare(b);
});
genreOrder.forEach(genre => {
const group = document.createElement('optgroup');
group.label = genre;
genres[genre].forEach(track => {
const option = document.createElement('option');
option.value = track.file;
option.textContent = track.name;
group.appendChild(option);
});
select.appendChild(group);
});
// Restore previous selection if it still exists
@@ -1225,3 +1270,93 @@ async function stopServer() {
log('Failed to stop server: ' + err.message);
}
}
// --- Voicemail ---
let _currentVmAudio = null;
async function loadVoicemails() {
try {
const res = await fetch('/api/voicemails');
const data = await res.json();
renderVoicemails(data);
} catch (err) {}
}
function renderVoicemails(voicemails) {
const list = document.getElementById('voicemail-list');
const badge = document.getElementById('voicemail-badge');
if (!list) return;
const unlistened = voicemails.filter(v => !v.listened).length;
if (badge) {
badge.textContent = unlistened;
badge.classList.toggle('hidden', unlistened === 0);
}
if (voicemails.length === 0) {
list.innerHTML = '<div class="queue-empty">No voicemails</div>';
return;
}
list.innerHTML = voicemails.map(v => {
const date = new Date(v.timestamp * 1000);
const timeStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const mins = Math.floor(v.duration / 60);
const secs = v.duration % 60;
const durStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
const unlistenedCls = v.listened ? '' : ' vm-unlistened';
return `<div class="vm-item${unlistenedCls}" data-id="${v.id}">
<div class="vm-info">
<span class="vm-phone">${v.phone}</span>
<span class="vm-time">${timeStr}</span>
<span class="vm-dur">${durStr}</span>
</div>
<div class="vm-actions">
<button class="vm-btn listen" onclick="listenVoicemail('${v.id}')">Listen</button>
<button class="vm-btn on-air" onclick="playVoicemailOnAir('${v.id}')">On Air</button>
<button class="vm-btn save" onclick="saveVoicemail('${v.id}')">Save</button>
<button class="vm-btn delete" onclick="deleteVoicemail('${v.id}')">Del</button>
</div>
</div>`;
}).join('');
}
function listenVoicemail(id) {
if (_currentVmAudio) {
_currentVmAudio.pause();
_currentVmAudio = null;
}
_currentVmAudio = new Audio(`/api/voicemail/${id}/audio`);
_currentVmAudio.play();
fetch(`/api/voicemail/${id}/mark-listened`, { method: 'POST' }).then(() => loadVoicemails());
}
async function playVoicemailOnAir(id) {
try {
await safeFetch(`/api/voicemail/${id}/play-on-air`, { method: 'POST' });
log('Playing voicemail on air');
loadVoicemails();
} catch (err) {
log('Failed to play voicemail: ' + err.message);
}
}
async function saveVoicemail(id) {
try {
await safeFetch(`/api/voicemail/${id}/save`, { method: 'POST' });
log('Voicemail saved to archive');
} catch (err) {
log('Failed to save voicemail: ' + err.message);
}
}
async function deleteVoicemail(id) {
if (!confirm('Delete this voicemail?')) return;
try {
await safeFetch(`/api/voicemail/${id}`, { method: 'DELETE' });
loadVoicemails();
} catch (err) {
log('Failed to delete voicemail: ' + err.message);
}
}

View File

@@ -20,6 +20,7 @@ import re
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
import requests
@@ -28,6 +29,8 @@ from dotenv import load_dotenv
load_dotenv(Path(__file__).parent / ".env")
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
RSS_FEED_URL = "https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml"
EPISODE_CACHE_DIR = Path(__file__).parent / "clips" / ".episode-cache"
WHISPER_MODEL_FAST = "base"
WHISPER_MODEL_QUALITY = "large-v3"
COVER_ART = Path(__file__).parent / "website" / "images" / "cover.png"
@@ -273,7 +276,7 @@ Respond with ONLY a JSON array, no markdown or explanation:
"Content-Type": "application/json",
},
json={
"model": "anthropic/claude-3.5-sonnet",
"model": "anthropic/claude-sonnet-4-5",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 2048,
"temperature": 0.3,
@@ -309,6 +312,70 @@ Respond with ONLY a JSON array, no markdown or explanation:
return validated
def generate_social_metadata(clips: list[dict], labeled_transcript: str,
episode_number: int | None) -> list[dict]:
"""Generate social media descriptions and hashtags for each clip."""
if not OPENROUTER_API_KEY:
print("Error: OPENROUTER_API_KEY not set in .env")
sys.exit(1)
clips_summary = "\n".join(
f'{i+1}. "{c["title"]}"{c["caption_text"]}'
for i, c in enumerate(clips)
)
episode_context = f"This is Episode {episode_number} of " if episode_number else "This is an episode of "
prompt = f"""{episode_context}the "Luke at the Roost" podcast — a late-night call-in show where AI-generated callers share stories, confessions, and hot takes with host Luke.
Here are {len(clips)} clips selected from this episode:
{clips_summary}
For each clip, generate:
1. description: A short, engaging description for social media (1-2 sentences, hook the viewer, conversational tone). Do NOT include hashtags in the description.
2. hashtags: An array of 5-8 hashtags. Always include #lukeattheroost and #podcast. Add topic-relevant and trending-style tags.
Respond with ONLY a JSON array matching the clip order:
[{{"description": "...", "hashtags": ["#tag1", "#tag2", ...]}}]"""
response = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": "anthropic/claude-sonnet-4-5",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 2048,
"temperature": 0.7,
},
)
if response.status_code != 200:
print(f"Error from OpenRouter: {response.text}")
return clips
content = response.json()["choices"][0]["message"]["content"].strip()
if content.startswith("```"):
content = re.sub(r"^```(?:json)?\n?", "", content)
content = re.sub(r"\n?```$", "", content)
try:
metadata = json.loads(content)
except json.JSONDecodeError as e:
print(f"Error parsing social metadata: {e}")
return clips
for i, clip in enumerate(clips):
if i < len(metadata):
clip["description"] = metadata[i].get("description", "")
clip["hashtags"] = metadata[i].get("hashtags", [])
return clips
def snap_to_sentences(clips: list[dict], segments: list[dict]) -> list[dict]:
"""Snap clip start/end times to sentence boundaries.
@@ -398,11 +465,10 @@ def get_words_in_range(segments: list[dict], start: float, end: float) -> list[d
return words
def _words_similar(a: str, b: str, max_dist: int = 2) -> bool:
"""Check if two words are within edit distance max_dist (Levenshtein)."""
if abs(len(a) - len(b)) > max_dist:
return False
# Simple DP edit distance, bounded
def _edit_distance(a: str, b: str) -> int:
"""Levenshtein edit distance between two strings."""
if abs(len(a) - len(b)) > 5:
return max(len(a), len(b))
prev = list(range(len(b) + 1))
for i in range(1, len(a) + 1):
curr = [i] + [0] * len(b)
@@ -410,139 +476,204 @@ def _words_similar(a: str, b: str, max_dist: int = 2) -> bool:
cost = 0 if a[i - 1] == b[j - 1] else 1
curr[j] = min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)
prev = curr
return prev[len(b)] <= max_dist
return prev[len(b)]
def _find_labeled_section(labeled_transcript: str, range_text: str) -> str | None:
"""Find the section of labeled transcript matching a Whisper text range."""
# Strip speaker labels and punctuation from labeled transcript for matching
labeled_stripped = re.sub(r'^[A-Z][A-Z\s\'-]+?:\s*', '', labeled_transcript, flags=re.MULTILINE)
labeled_clean = re.sub(r'[^\w\s]', '', labeled_stripped.lower())
labeled_clean = re.sub(r'\s+', ' ', labeled_clean)
whisper_clean = re.sub(r'[^\w\s]', '', range_text.lower())
whisper_clean = re.sub(r'\s+', ' ', whisper_clean)
whisper_words_list = whisper_clean.split()
# Try progressively shorter phrases from different positions
for phrase_len in [10, 7, 5, 3]:
for start_offset in [0, len(whisper_words_list) // 3, len(whisper_words_list) // 2]:
words_slice = whisper_words_list[start_offset:start_offset + phrase_len]
phrase = " ".join(words_slice)
if len(phrase) < 8:
continue
pos = labeled_clean.find(phrase)
if pos != -1:
# Map back to original transcript — find first word near this position
match_pos = labeled_transcript.lower().find(
words_slice[0], max(0, pos - 300))
if match_pos == -1:
match_pos = max(0, pos)
else:
match_pos = max(0, match_pos - start_offset * 6)
context_start = max(0, match_pos - 400)
context_end = min(len(labeled_transcript), match_pos + len(range_text) + 600)
return labeled_transcript[context_start:context_end]
return None
def _word_score(a: str, b: str) -> int:
"""Alignment score: +2 exact, +1 fuzzy (edit dist ≤2), -1 mismatch."""
if a == b:
return 2
if len(a) >= 3 and len(b) >= 3 and _edit_distance(a, b) <= 2:
return 1
return -1
def _parse_labeled_words(labeled_section: str) -> list[tuple[str, str, str]]:
"""Parse speaker-labeled text into (original_word, clean_lower, speaker) tuples."""
def _align_sequences(whisper_words: list[str],
labeled_words: list[str]) -> list[tuple[int | None, int | None]]:
"""Needleman-Wunsch DP alignment between whisper and labeled word sequences.
Returns list of (whisper_idx, labeled_idx) pairs where None = gap.
"""
n = len(whisper_words)
m = len(labeled_words)
GAP = -1
# Build score matrix
score = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
score[i][0] = score[i - 1][0] + GAP
for j in range(1, m + 1):
score[0][j] = score[0][j - 1] + GAP
for i in range(1, n + 1):
for j in range(1, m + 1):
match = score[i - 1][j - 1] + _word_score(whisper_words[i - 1], labeled_words[j - 1])
delete = score[i - 1][j] + GAP
insert = score[i][j - 1] + GAP
score[i][j] = max(match, delete, insert)
# Traceback
pairs = []
i, j = n, m
while i > 0 or j > 0:
if i > 0 and j > 0 and score[i][j] == score[i - 1][j - 1] + _word_score(whisper_words[i - 1], labeled_words[j - 1]):
pairs.append((i - 1, j - 1))
i -= 1
j -= 1
elif i > 0 and score[i][j] == score[i - 1][j] + GAP:
pairs.append((i - 1, None))
i -= 1
else:
pairs.append((None, j - 1))
j -= 1
pairs.reverse()
return pairs
def _parse_full_transcript(labeled_transcript: str) -> list[dict]:
"""Parse entire labeled transcript into flat word list with speaker metadata.
Returns list of {word: str, clean: str, speaker: str} for every word.
"""
result = []
for m in re.finditer(r'^([A-Z][A-Z\s\'-]+?):\s*(.+?)(?=\n[A-Z][A-Z\s\'-]+?:|\n\n|\Z)',
labeled_section, re.MULTILINE | re.DOTALL):
labeled_transcript, re.MULTILINE | re.DOTALL):
speaker = m.group(1).strip()
text = m.group(2)
for w in text.split():
original = w.strip()
clean = re.sub(r"[^\w']", '', original.lower())
if clean:
result.append((original, clean, speaker))
result.append({"word": original, "clean": clean, "speaker": speaker})
return result
def _find_transcript_region(labeled_words: list[dict], whisper_words: list[str],
) -> tuple[int, int] | None:
"""Find the region of labeled_words that best matches the whisper words.
Uses multi-anchor matching: tries phrases from start, middle, and end
of the whisper words to find a consensus region.
"""
if not whisper_words or not labeled_words:
return None
labeled_clean = [w["clean"] for w in labeled_words]
n_labeled = len(labeled_clean)
def find_phrase(phrase_words: list[str], search_start: int = 0,
search_end: int | None = None) -> int | None:
"""Find a phrase in labeled_clean, return index of first word or None."""
if search_end is None:
search_end = n_labeled
plen = len(phrase_words)
for i in range(search_start, min(search_end, n_labeled - plen + 1)):
match = True
for k in range(plen):
if _word_score(phrase_words[k], labeled_clean[i + k]) < 1:
match = False
break
if match:
return i
return None
# Try anchors from different positions in the whisper words
anchors = []
n_whisper = len(whisper_words)
anchor_positions = [0, n_whisper // 2, max(0, n_whisper - 5)]
# Deduplicate positions
anchor_positions = sorted(set(anchor_positions))
for pos in anchor_positions:
for phrase_len in [5, 4, 3]:
phrase = whisper_words[pos:pos + phrase_len]
if len(phrase) < 3:
continue
idx = find_phrase(phrase)
if idx is not None:
# Estimate region start based on anchor's position in whisper
region_start = max(0, idx - pos)
anchors.append(region_start)
break
if not anchors:
return None
# Use median anchor as region start for robustness
anchors.sort()
region_start = anchors[len(anchors) // 2]
# Region extends to cover all whisper words plus margin
margin = max(20, n_whisper // 4)
region_start = max(0, region_start - margin)
region_end = min(n_labeled, region_start + n_whisper + 2 * margin)
return (region_start, region_end)
def add_speaker_labels(words: list[dict], labeled_transcript: str,
start_time: float, end_time: float,
segments: list[dict]) -> list[dict]:
"""Add speaker labels AND correct word text using labeled transcript.
Uses Whisper only for timestamps. Takes text from the labeled transcript,
which has correct names and spelling. Aligns using greedy forward matching
with edit-distance fuzzy matching.
Uses Needleman-Wunsch DP alignment to match Whisper words to the labeled
transcript. This handles insertions/deletions gracefully — one missed word
becomes a single gap instead of cascading failures.
"""
if not labeled_transcript or not words:
return words
# Get the raw Whisper text for this time range
range_text = ""
for seg in segments:
if seg["end"] < start_time or seg["start"] > end_time:
continue
range_text += " " + seg["text"]
range_text = range_text.strip()
# Find matching section in labeled transcript
labeled_section = _find_labeled_section(labeled_transcript, range_text)
if not labeled_section:
# Parse full transcript into flat word list
all_labeled = _parse_full_transcript(labeled_transcript)
if not all_labeled:
return words
labeled_words_flat = _parse_labeled_words(labeled_section)
if not labeled_words_flat:
# Build whisper clean word list
whisper_clean = []
for w in words:
clean = re.sub(r"[^\w']", '', w["word"].lower())
whisper_clean.append(clean if clean else w["word"].lower())
# Find the matching region in the transcript
region = _find_transcript_region(all_labeled, whisper_clean)
if region is None:
return words
# Greedy forward alignment: for each Whisper word, find best match
# in labeled words within a lookahead window
labeled_idx = 0
current_speaker = labeled_words_flat[0][2]
region_start, region_end = region
region_words = all_labeled[region_start:region_end]
region_clean = [w["clean"] for w in region_words]
# Run DP alignment
pairs = _align_sequences(whisper_clean, region_clean)
# Build speaker assignments from aligned pairs
# matched[whisper_idx] = (labeled_word_dict, score)
matched = {}
for w_idx, l_idx in pairs:
if w_idx is not None and l_idx is not None:
score = _word_score(whisper_clean[w_idx], region_clean[l_idx])
if score > 0:
matched[w_idx] = (region_words[l_idx], score)
# Apply matches and interpolate speakers for gaps
corrections = 0
for i, word_entry in enumerate(words):
if i in matched:
labeled_word, score = matched[i]
word_entry["speaker"] = labeled_word["speaker"]
for word_entry in words:
whisper_clean = re.sub(r"[^\w']", '', word_entry["word"].lower())
if not whisper_clean:
word_entry["speaker"] = current_speaker
continue
# Search forward for best match
best_idx = None
best_score = 0 # 2 = exact, 1 = fuzzy
window = min(labeled_idx + 12, len(labeled_words_flat))
for j in range(labeled_idx, window):
labeled_clean = labeled_words_flat[j][1]
if labeled_clean == whisper_clean:
best_idx = j
best_score = 2
break
if len(whisper_clean) >= 3 and len(labeled_clean) >= 3:
if _words_similar(whisper_clean, labeled_clean):
if best_score < 1:
best_idx = j
best_score = 1
# Don't break — keep looking for exact match
if best_idx is not None:
original_word, _, speaker = labeled_words_flat[best_idx]
current_speaker = speaker
# Replace Whisper's word with correct version
corrected = re.sub(r'[^\w\s\'-]', '', original_word)
if corrected and corrected.lower() != whisper_clean:
# Replace text only on confident matches
corrected = re.sub(r'[^\w\s\'-]', '', labeled_word["word"])
if corrected:
if corrected.lower() != whisper_clean[i]:
corrections += 1
word_entry["word"] = corrected
corrections += 1
elif corrected:
word_entry["word"] = corrected
labeled_idx = best_idx + 1
else:
# No match — advance labeled pointer by 1 to stay roughly in sync
if labeled_idx < len(labeled_words_flat):
labeled_idx += 1
word_entry["speaker"] = current_speaker
# Interpolate speaker from nearest matched neighbor
speaker = _interpolate_speaker(i, matched, len(words))
if speaker:
word_entry["speaker"] = speaker
if corrections:
print(f" Corrected {corrections} words from labeled transcript")
@@ -550,6 +681,19 @@ def add_speaker_labels(words: list[dict], labeled_transcript: str,
return words
def _interpolate_speaker(idx: int, matched: dict, n_words: int) -> str | None:
"""Find speaker from nearest matched neighbor."""
# Search outward from idx
for dist in range(1, n_words):
before = idx - dist
after = idx + dist
if before >= 0 and before in matched:
return matched[before][0]["speaker"]
if after < n_words and after in matched:
return matched[after][0]["speaker"]
return None
def group_words_into_lines(words: list[dict], clip_start: float,
clip_duration: float) -> list[dict]:
"""Group words into timed caption lines for rendering.
@@ -894,9 +1038,123 @@ def detect_episode_number(audio_path: str) -> int | None:
return None
def fetch_episodes() -> list[dict]:
"""Fetch episode list from Castopod RSS feed."""
print("Fetching episodes from Castopod...")
try:
resp = requests.get(RSS_FEED_URL, timeout=15)
resp.raise_for_status()
except requests.RequestException as e:
print(f"Error fetching RSS feed: {e}")
sys.exit(1)
root = ET.fromstring(resp.content)
ns = {"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd"}
episodes = []
for item in root.findall(".//item"):
title = item.findtext("title", "")
enclosure = item.find("enclosure")
audio_url = enclosure.get("url", "") if enclosure is not None else ""
duration = item.findtext("itunes:duration", "", ns)
ep_num = item.findtext("itunes:episode", "", ns)
pub_date = item.findtext("pubDate", "")
if not audio_url:
continue
episodes.append({
"title": title,
"audio_url": audio_url,
"duration": duration,
"episode_number": int(ep_num) if ep_num and ep_num.isdigit() else None,
"pub_date": pub_date,
})
return episodes
def pick_episode(episodes: list[dict]) -> dict:
"""Display episode list and let user pick one."""
if not episodes:
print("No episodes found.")
sys.exit(1)
# Sort by episode number (episodes without numbers go to the end)
episodes.sort(key=lambda e: (e["episode_number"] is None, e["episode_number"] or 0))
print(f"\nFound {len(episodes)} episodes:\n")
for ep in episodes:
num = ep['episode_number']
label = f"Ep{num}" if num else " "
dur = ep['duration'] or "?"
display_num = f"{num:>2}" if num else " ?"
print(f" {display_num}. [{label:>4}] {ep['title']} ({dur})")
print()
while True:
try:
choice = input("Select episode number (or 'q' to quit): ").strip()
if choice.lower() == 'q':
sys.exit(0)
num = int(choice)
# Match by episode number first
match = next((ep for ep in episodes if ep["episode_number"] == num), None)
if match:
return match
print(f" No episode #{num} found. Episodes: {', '.join(str(e['episode_number']) for e in episodes if e['episode_number'])}")
except (ValueError, EOFError):
print(" Enter an episode number")
def download_episode(episode: dict) -> Path:
"""Download episode audio, using a cache to avoid re-downloading."""
EPISODE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
# Build a filename from episode number or title slug
if episode["episode_number"]:
filename = f"episode-{episode['episode_number']}.mp3"
else:
filename = slugify(episode["title"]) + ".mp3"
cached = EPISODE_CACHE_DIR / filename
if cached.exists():
size_mb = cached.stat().st_size / (1024 * 1024)
print(f"Using cached: {cached.name} ({size_mb:.1f} MB)")
return cached
print(f"Downloading: {episode['title']}...")
try:
resp = requests.get(episode["audio_url"], stream=True, timeout=30)
resp.raise_for_status()
total = int(resp.headers.get("content-length", 0))
downloaded = 0
with open(cached, "wb") as f:
for chunk in resp.iter_content(chunk_size=1024 * 1024):
f.write(chunk)
downloaded += len(chunk)
if total:
pct = downloaded / total * 100
print(f"\r {downloaded / (1024*1024):.1f} / {total / (1024*1024):.1f} MB ({pct:.0f}%)", end="", flush=True)
else:
print(f"\r {downloaded / (1024*1024):.1f} MB", end="", flush=True)
print()
except requests.RequestException as e:
if cached.exists():
cached.unlink()
print(f"\nError downloading episode: {e}")
sys.exit(1)
size_mb = cached.stat().st_size / (1024 * 1024)
print(f"Saved: {cached.name} ({size_mb:.1f} MB)")
return cached
def main():
parser = argparse.ArgumentParser(description="Extract short-form clips from podcast episodes")
parser.add_argument("audio_file", help="Path to episode MP3")
parser.add_argument("audio_file", nargs="?", help="Path to episode MP3 (optional if using --pick)")
parser.add_argument("--pick", "-p", action="store_true",
help="Pick an episode from Castopod to clip")
parser.add_argument("--transcript", "-t", help="Path to labeled transcript (.txt)")
parser.add_argument("--chapters", "-c", help="Path to chapters JSON")
parser.add_argument("--count", "-n", type=int, default=3, help="Number of clips to extract (default: 3)")
@@ -911,13 +1169,27 @@ def main():
help="Use quality model for everything (slower, no two-pass)")
args = parser.parse_args()
audio_path = Path(args.audio_file).expanduser().resolve()
if not audio_path.exists():
print(f"Error: Audio file not found: {audio_path}")
sys.exit(1)
# Default to --pick when no audio file provided
if not args.audio_file and not args.pick:
args.pick = True
if args.pick:
episodes = fetch_episodes()
selected = pick_episode(episodes)
audio_path = download_episode(selected)
episode_number = selected["episode_number"] or args.episode_number
else:
audio_path = Path(args.audio_file).expanduser().resolve()
if not audio_path.exists():
print(f"Error: Audio file not found: {audio_path}")
sys.exit(1)
episode_number = None
# Detect episode number
episode_number = args.episode_number or detect_episode_number(str(audio_path))
if not args.pick:
episode_number = args.episode_number or detect_episode_number(str(audio_path))
if args.episode_number:
episode_number = args.episode_number
# Resolve output directory
if args.output_dir:
@@ -959,9 +1231,9 @@ def main():
# Step 2: Fast transcription for clip identification
two_pass = not args.single_pass and args.fast_model != args.quality_model
if two_pass:
print(f"\n[2/6] Fast transcription for clip identification ({args.fast_model})...")
print(f"\n[2/7] Fast transcription for clip identification ({args.fast_model})...")
else:
print(f"\n[2/5] Transcribing with word-level timestamps ({args.quality_model})...")
print(f"\n[2/6] Transcribing with word-level timestamps ({args.quality_model})...")
identify_model = args.fast_model if two_pass else args.quality_model
segments = transcribe_with_timestamps(
str(audio_path), identify_model, labeled_transcript
@@ -980,7 +1252,7 @@ def main():
print(f" Chapters loaded: {chapters_path.name}")
# Step 3: LLM selects best moments
step_total = 6 if two_pass else 5
step_total = 7 if two_pass else 6
print(f"\n[3/{step_total}] Selecting {args.count} best moments with LLM...")
clips = select_clips_with_llm(transcript_text, labeled_transcript,
chapters_json, args.count)
@@ -994,10 +1266,19 @@ def main():
f"({clip['start_time']:.1f}s - {clip['end_time']:.1f}s, {duration:.0f}s)")
print(f" \"{clip['caption_text']}\"")
# Step 4: Refine clip timestamps with quality model (two-pass only)
# Generate social media metadata
meta_step = 4
print(f"\n[{meta_step}/{step_total}] Generating social media descriptions...")
clips = generate_social_metadata(clips, labeled_transcript, episode_number)
for i, clip in enumerate(clips):
if "description" in clip:
print(f" Clip {i+1}: {clip['description'][:80]}...")
print(f" {' '.join(clip.get('hashtags', []))}")
# Step 5: Refine clip timestamps with quality model (two-pass only)
refined = {}
if two_pass:
print(f"\n[4/{step_total}] Refining clips with {args.quality_model}...")
print(f"\n[5/{step_total}] Refining clips with {args.quality_model}...")
refined = refine_clip_timestamps(
str(audio_path), clips, args.quality_model, labeled_transcript
)
@@ -1008,7 +1289,7 @@ def main():
clips[i:i+1] = snap_to_sentences([clip], clip_segments)
# Step N: Extract audio clips
extract_step = 5 if two_pass else 4
extract_step = 6 if two_pass else 5
print(f"\n[{extract_step}/{step_total}] Extracting audio clips...")
for i, clip in enumerate(clips):
slug = slugify(clip["title"])
@@ -1020,7 +1301,7 @@ def main():
else:
print(f" Error extracting clip {i+1} audio")
video_step = 6 if two_pass else 5
video_step = 7 if two_pass else 6
if args.audio_only:
print(f"\n[{video_step}/{step_total}] Skipped video generation (--audio-only)")
print(f"\nDone! {len(clips)} audio clips saved to {output_dir}")
@@ -1071,6 +1352,27 @@ def main():
else:
print(f" Error generating clip {i+1} video")
# Save clips metadata for social upload
metadata_path = output_dir / "clips-metadata.json"
metadata = []
for i, clip in enumerate(clips):
slug = slugify(clip["title"])
metadata.append({
"title": clip["title"],
"clip_file": f"clip-{i+1}-{slug}.mp4",
"audio_file": f"clip-{i+1}-{slug}.mp3",
"caption_text": clip.get("caption_text", ""),
"description": clip.get("description", ""),
"hashtags": clip.get("hashtags", []),
"start_time": clip["start_time"],
"end_time": clip["end_time"],
"duration": round(clip["end_time"] - clip["start_time"], 1),
"episode_number": episode_number,
})
with open(metadata_path, "w") as f:
json.dump(metadata, f, indent=2)
print(f"\nSocial metadata: {metadata_path}")
# Summary
print(f"\nDone! {len(clips)} clips saved to {output_dir}")
for i, clip in enumerate(clips):

View File

@@ -60,7 +60,7 @@ PODCAST_ID = 1
PODCAST_HANDLE = "LukeAtTheRoost"
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
WHISPER_MODEL = "large-v3"
WHISPER_MODEL = "distil-large-v3"
# Postiz (social media posting)
POSTIZ_URL = "https://social.lukeattheroost.com"
@@ -189,35 +189,41 @@ TRANSCRIPT:
def transcribe_audio(audio_path: str) -> dict:
"""Transcribe audio using faster-whisper with timestamps."""
print(f"[1/5] Transcribing {audio_path}...")
"""Transcribe audio using Lightning Whisper MLX (Apple Silicon GPU)."""
print(f"[1/5] Transcribing {audio_path} (MLX GPU)...")
try:
from faster_whisper import WhisperModel
from lightning_whisper_mlx import LightningWhisperMLX
except ImportError:
print("Error: faster-whisper not installed. Run: pip install faster-whisper")
print("Error: lightning-whisper-mlx not installed. Run: pip install lightning-whisper-mlx")
sys.exit(1)
model = WhisperModel(WHISPER_MODEL, compute_type="int8")
segments, info = model.transcribe(audio_path, word_timestamps=True)
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", audio_path],
capture_output=True, text=True
)
duration = int(float(probe.stdout.strip())) if probe.returncode == 0 else 0
whisper = LightningWhisperMLX(model=WHISPER_MODEL, batch_size=12, quant=None)
result = whisper.transcribe(audio_path=audio_path, language="en")
transcript_segments = []
full_text = []
for segment in segments:
for segment in result.get("segments", []):
start_ms, end_ms, text = segment[0], segment[1], segment[2]
transcript_segments.append({
"start": segment.start,
"end": segment.end,
"text": segment.text.strip()
"start": start_ms / 1000.0,
"end": end_ms / 1000.0,
"text": text.strip()
})
full_text.append(segment.text.strip())
print(f" Transcribed {info.duration:.1f} seconds of audio")
full_text.append(text.strip())
print(f" Transcribed {duration} seconds of audio ({len(transcript_segments)} segments)")
return {
"segments": transcript_segments,
"full_text": " ".join(full_text),
"duration": int(info.duration)
"duration": duration
}

401
upload_clips.py Normal file
View File

@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""Upload podcast clips to social media via Postiz (and direct Bluesky via atproto).
Usage:
python upload_clips.py clips/episode-12/
python upload_clips.py clips/episode-12/ --clip 1
python upload_clips.py clips/episode-12/ --platforms ig,yt
python upload_clips.py clips/episode-12/ --schedule "2026-02-16T10:00:00"
python upload_clips.py clips/episode-12/ --yes # skip confirmation
"""
import argparse
import json
import sys
from pathlib import Path
import requests
from atproto import Client as BskyClient
from dotenv import load_dotenv
import os
load_dotenv(Path(__file__).parent / ".env")
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
BSKY_HANDLE = os.getenv("BSKY_HANDLE", "lukeattheroost.bsky.social")
BSKY_APP_PASSWORD = os.getenv("BSKY_APP_PASSWORD")
PLATFORM_ALIASES = {
"ig": "instagram", "insta": "instagram", "instagram": "instagram",
"yt": "youtube", "youtube": "youtube",
"fb": "facebook", "facebook": "facebook",
"bsky": "bluesky", "bluesky": "bluesky",
"masto": "mastodon", "mastodon": "mastodon",
"nostr": "nostr",
}
PLATFORM_DISPLAY = {
"instagram": "Instagram Reels",
"youtube": "YouTube Shorts",
"facebook": "Facebook Reels",
"bluesky": "Bluesky",
"mastodon": "Mastodon",
"nostr": "Nostr",
}
ALL_PLATFORMS = list(PLATFORM_DISPLAY.keys())
def get_api_url(path: str) -> str:
base = POSTIZ_URL.rstrip("/")
return f"{base}/api/public/v1{path}"
def api_headers() -> dict:
return {
"Authorization": POSTIZ_API_KEY,
"Content-Type": "application/json",
}
def fetch_integrations() -> list[dict]:
resp = requests.get(get_api_url("/integrations"), headers=api_headers(), timeout=15)
if resp.status_code != 200:
print(f"Error fetching integrations: {resp.status_code} {resp.text[:200]}")
sys.exit(1)
return resp.json()
def find_integration(integrations: list[dict], provider: str) -> dict | None:
for integ in integrations:
if integ.get("identifier", "").startswith(provider) and not integ.get("disabled"):
return integ
return None
def upload_file(file_path: Path) -> dict:
headers = {"Authorization": POSTIZ_API_KEY}
with open(file_path, "rb") as f:
resp = requests.post(
get_api_url("/upload"),
headers=headers,
files={"file": (file_path.name, f, "video/mp4")},
timeout=120,
)
if resp.status_code not in (200, 201):
print(f"Upload failed: {resp.status_code} {resp.text[:200]}")
return {}
return resp.json()
def build_content(clip: dict, platform: str) -> str:
desc = clip.get("description", clip.get("caption_text", ""))
hashtags = clip.get("hashtags", [])
hashtag_str = " ".join(hashtags)
if platform == "bluesky":
if hashtags and len(desc) + 2 + len(hashtag_str) <= 300:
return desc + "\n\n" + hashtag_str
return desc[:300]
parts = [desc]
if hashtags:
parts.append("\n\n" + hashtag_str)
if platform in ("youtube", "facebook"):
parts.append("\n\nListen to the full episode: lukeattheroost.com")
return "".join(parts)
def build_settings(clip: dict, platform: str) -> dict:
if platform == "instagram":
return {"__type": "instagram", "post_type": "post", "collaborators": []}
if platform == "youtube":
yt_tags = [{"value": h.lstrip("#"), "label": h.lstrip("#")}
for h in clip.get("hashtags", [])]
return {
"__type": "youtube",
"title": clip["title"],
"type": "public",
"selfDeclaredMadeForKids": "no",
"thumbnail": None,
"tags": yt_tags,
}
return {"__type": platform}
def post_to_bluesky(clip: dict, clip_file: Path) -> bool:
"""Post a clip directly to Bluesky via atproto (bypasses Postiz)."""
import time
import httpx
from atproto import models
if not BSKY_APP_PASSWORD:
print(" Error: BSKY_APP_PASSWORD not set in .env")
return False
client = BskyClient()
client.login(BSKY_HANDLE, BSKY_APP_PASSWORD)
did = client.me.did
video_data = clip_file.read_bytes()
# Get a service auth token scoped to the user's PDS (required by video service)
from urllib.parse import urlparse
pds_host = urlparse(client._session.pds_endpoint).hostname
service_auth = client.com.atproto.server.get_service_auth(
{"aud": f"did:web:{pds_host}", "lxm": "com.atproto.repo.uploadBlob"}
)
token = service_auth.token
# Upload video to Bluesky's video processing service (not the PDS)
print(f" Uploading video ({len(video_data) / 1_000_000:.1f} MB)...")
upload_resp = httpx.post(
"https://video.bsky.app/xrpc/app.bsky.video.uploadVideo",
params={"did": did, "name": clip_file.name},
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "video/mp4",
},
content=video_data,
timeout=120,
)
if upload_resp.status_code not in (200, 409):
print(f" Upload failed: {upload_resp.status_code} {upload_resp.text[:200]}")
return False
upload_data = upload_resp.json()
job_id = upload_data.get("jobId") or upload_data.get("jobStatus", {}).get("jobId")
if not job_id:
print(f" No jobId returned: {upload_resp.text[:200]}")
return False
print(f" Video processing (job {job_id})...")
# Poll until video is processed
session_token = client._session.access_jwt
blob = None
while True:
status_resp = httpx.get(
"https://video.bsky.app/xrpc/app.bsky.video.getJobStatus",
params={"jobId": job_id},
headers={"Authorization": f"Bearer {session_token}"},
timeout=15,
)
resp_data = status_resp.json()
status = resp_data.get("jobStatus") or resp_data
state = status.get("state")
if state == "JOB_STATE_COMPLETED":
blob = status.get("blob")
break
if state == "JOB_STATE_FAILED":
err = status.get("error") or status.get("message") or "unknown"
print(f" Video processing failed: {err}")
return False
progress = status.get("progress", 0)
print(f" Processing... {progress}%")
time.sleep(3)
if not blob:
print(" No blob returned after processing")
return False
text = build_content(clip, "bluesky")
embed = models.AppBskyEmbedVideo.Main(
video=models.blob_ref.BlobRef(
mime_type=blob["mimeType"],
size=blob["size"],
ref=models.blob_ref.IpldLink(link=blob["ref"]["$link"]),
),
alt=clip.get("caption_text", clip["title"]),
aspect_ratio=models.AppBskyEmbedDefs.AspectRatio(width=1080, height=1920),
)
client.send_post(text=text, embed=embed)
return True
def create_post(integration_id: str, content: str, media: dict,
settings: dict, schedule: str | None = None) -> dict:
from datetime import datetime, timezone
post_type = "schedule" if schedule else "now"
date = schedule or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
payload = {
"type": post_type,
"date": date,
"shortLink": False,
"tags": [],
"posts": [
{
"integration": {"id": integration_id},
"value": [
{
"content": content,
"image": [media] if media else [],
}
],
"settings": settings,
}
],
}
resp = requests.post(
get_api_url("/posts"),
headers=api_headers(),
json=payload,
timeout=30,
)
if resp.status_code not in (200, 201):
print(f"Post creation failed: {resp.status_code} {resp.text[:300]}")
return {}
return resp.json()
def main():
valid_names = sorted(set(PLATFORM_ALIASES.keys()))
parser = argparse.ArgumentParser(description="Upload podcast clips to social media via Postiz")
parser.add_argument("clips_dir", help="Path to clips directory (e.g. clips/episode-12/)")
parser.add_argument("--clip", "-c", type=int, help="Upload only clip N (1-indexed)")
parser.add_argument("--platforms", "-p",
help=f"Comma-separated platforms ({','.join(ALL_PLATFORMS)}). Default: all")
parser.add_argument("--schedule", "-s", help="Schedule time (ISO 8601, e.g. 2026-02-16T10:00:00)")
parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
parser.add_argument("--dry-run", action="store_true", help="Show what would be uploaded without posting")
args = parser.parse_args()
if not POSTIZ_API_KEY:
print("Error: POSTIZ_API_KEY not set in .env")
sys.exit(1)
if args.platforms:
requested = []
for p in args.platforms.split(","):
p = p.strip().lower()
if p not in PLATFORM_ALIASES:
print(f"Unknown platform: {p}")
print(f"Valid: {', '.join(valid_names)}")
sys.exit(1)
requested.append(PLATFORM_ALIASES[p])
target_platforms = list(dict.fromkeys(requested))
else:
target_platforms = ALL_PLATFORMS[:]
clips_dir = Path(args.clips_dir).expanduser().resolve()
metadata_path = clips_dir / "clips-metadata.json"
if not metadata_path.exists():
print(f"Error: No clips-metadata.json found in {clips_dir}")
print("Run make_clips.py first to generate clips and metadata.")
sys.exit(1)
with open(metadata_path) as f:
clips = json.load(f)
if args.clip:
if args.clip < 1 or args.clip > len(clips):
print(f"Error: Clip {args.clip} not found (have {len(clips)} clips)")
sys.exit(1)
clips = [clips[args.clip - 1]]
needs_postiz = not args.dry_run and any(
p != "bluesky" for p in target_platforms)
if needs_postiz:
print("Fetching connected accounts from Postiz...")
integrations = fetch_integrations()
else:
integrations = []
active_platforms = {}
for platform in target_platforms:
if platform == "bluesky":
if BSKY_APP_PASSWORD or args.dry_run:
active_platforms[platform] = {"name": BSKY_HANDLE, "_direct": True}
else:
print("Warning: BSKY_APP_PASSWORD not set in .env, skipping Bluesky")
continue
if args.dry_run:
active_platforms[platform] = {"name": PLATFORM_DISPLAY[platform]}
continue
integ = find_integration(integrations, platform)
if integ:
active_platforms[platform] = integ
else:
print(f"Warning: No {PLATFORM_DISPLAY[platform]} account connected in Postiz")
if not args.dry_run and not active_platforms:
print("Error: No platforms available to upload to")
sys.exit(1)
platform_names = [f"{PLATFORM_DISPLAY[p]} ({integ.get('name', 'connected')})"
for p, integ in active_platforms.items()]
print(f"\nUploading {len(clips)} clip(s) to: {', '.join(platform_names)}")
if args.schedule:
print(f"Scheduled for: {args.schedule}")
print()
for i, clip in enumerate(clips):
print(f" {i+1}. \"{clip['title']}\" ({clip['duration']:.0f}s)")
desc = clip.get('description', '')
if len(desc) > 80:
desc = desc[:desc.rfind(' ', 0, 80)] + '...'
print(f" {desc}")
print(f" {' '.join(clip.get('hashtags', []))}")
print()
if args.dry_run:
print("Dry run — nothing uploaded.")
return
if not args.yes:
confirm = input("Proceed? [y/N] ").strip().lower()
if confirm != "y":
print("Cancelled.")
return
for i, clip in enumerate(clips):
clip_file = clips_dir / clip["clip_file"]
if not clip_file.exists():
print(f" Clip {i+1}: Video file not found: {clip_file}")
continue
print(f"\n Clip {i+1}: \"{clip['title']}\"")
postiz_platforms = {p: integ for p, integ in active_platforms.items()
if not integ.get("_direct")}
media = None
if postiz_platforms:
print(f" Uploading {clip_file.name}...")
media = upload_file(clip_file)
if not media:
print(" Failed to upload video to Postiz, skipping Postiz platforms")
postiz_platforms = {}
else:
print(f" Uploaded: {media.get('path', 'ok')}")
for platform, integ in postiz_platforms.items():
display = PLATFORM_DISPLAY[platform]
print(f" Posting to {display}...")
content = build_content(clip, platform)
settings = build_settings(clip, platform)
result = create_post(integ["id"], content, media, settings, args.schedule)
if result:
print(f" {display}: Posted!")
else:
print(f" {display}: Failed")
if "bluesky" in active_platforms:
print(f" Posting to Bluesky (direct)...")
try:
if post_to_bluesky(clip, clip_file):
print(f" Bluesky: Posted!")
else:
print(f" Bluesky: Failed")
except Exception as e:
print(f" Bluesky: Failed — {e}")
print("\nDone!")
if __name__ == "__main__":
main()

64
website/_worker.js Normal file
View File

@@ -0,0 +1,64 @@
const VOICEMAIL_XML = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="woman">Luke at the Roost is off the air right now. Leave a message after the beep and we may play it on the next show!</Say>
<Record maxLength="120" action="https://radioshow.macneilmediagroup.com/api/signalwire/voicemail-complete" playBeep="true" />
<Say voice="woman">Thank you for calling. Goodbye!</Say>
<Hangup/>
</Response>`;
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/api/signalwire/voice") {
try {
const body = await request.text();
const resp = await fetch("https://radioshow.macneilmediagroup.com/api/signalwire/voice", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body,
signal: AbortSignal.timeout(5000),
});
if (resp.ok) {
return new Response(await resp.text(), {
status: 200,
headers: { "Content-Type": "application/xml" },
});
}
} catch (e) {
// Server unreachable or timed out
}
return new Response(VOICEMAIL_XML, {
status: 200,
headers: { "Content-Type": "application/xml" },
});
}
// RSS feed proxy
if (url.pathname === "/feed") {
try {
const resp = await fetch("https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml", {
signal: AbortSignal.timeout(8000),
});
if (resp.ok) {
return new Response(await resp.text(), {
status: 200,
headers: {
"Content-Type": "application/xml",
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=300",
},
});
}
} catch (e) {
// Castopod unreachable
}
return new Response("Feed unavailable", { status: 502 });
}
// All other requests — serve static assets
return env.ASSETS.fetch(request);
},
};

View File

@@ -88,46 +88,39 @@ a:hover {
}
.phone {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
margin-top: 0.5rem;
}
.phone-number {
font-size: 2.2rem;
font-weight: 800;
color: var(--accent);
letter-spacing: 0.02em;
display: block;
}
.phone-digits {
.phone-inline {
font-size: 0.95rem;
color: var(--text-muted);
}
.phone-label {
font-size: 0.85rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.15em;
margin-bottom: 0.25rem;
.phone-inline strong {
color: var(--text);
font-weight: 700;
letter-spacing: 0.02em;
}
/* On-Air Badge */
.on-air-badge {
display: none;
align-items: center;
justify-content: center;
gap: 0.5rem;
gap: 0.4rem;
background: var(--accent-red);
color: #fff;
padding: 0.4rem 1.2rem;
padding: 0.25rem 0.75rem;
border-radius: 50px;
font-size: 0.85rem;
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
animation: on-air-glow 2s ease-in-out infinite;
margin-bottom: 0.5rem;
flex-shrink: 0;
}
.on-air-badge.visible {
@@ -156,99 +149,98 @@ a:hover {
.off-air-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: #444;
color: var(--text-muted);
padding: 0.35rem 1.1rem;
padding: 0.25rem 0.75rem;
border-radius: 50px;
font-size: 0.8rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 0.5rem;
flex-shrink: 0;
}
.off-air-badge.hidden {
display: none;
}
.phone.live .phone-number {
.phone.live .phone-inline strong {
color: var(--accent-red);
text-shadow: 0 0 16px rgba(204, 34, 34, 0.35);
}
.phone.live .phone-label {
color: var(--text);
}
/* Subscribe buttons — primary listen platforms */
.subscribe-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
flex-direction: column;
align-items: center;
gap: 0.6rem;
margin-top: 1.5rem;
}
.subscribe-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.15em;
}
.subscribe-buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.subscribe-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 1.1rem;
gap: 0.4rem;
padding: 0.45rem 1rem;
border-radius: 50px;
font-size: 0.85rem;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
transition: opacity 0.2s, transform 0.2s;
color: var(--text);
background: transparent;
border: 1px solid var(--text-muted);
transition: border-color 0.2s, color 0.2s;
}
.subscribe-btn:hover {
opacity: 0.85;
transform: translateY(-1px);
color: #fff;
border-color: var(--accent);
color: var(--accent);
}
.subscribe-btn svg {
width: 16px;
height: 16px;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.btn-spotify { background: #1DB954; }
.btn-youtube { background: #FF0000; }
.btn-apple { background: #A033FF; }
/* Secondary links — How It Works, Discord, RSS */
/* Secondary links — How It Works, Discord, Support */
.secondary-links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1.25rem;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.secondary-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 50px;
padding: 0.3rem 0.85rem;
transition: background 0.2s, color 0.2s;
font-size: 0.8rem;
color: var(--text-muted);
transition: color 0.2s;
}
.secondary-link:hover {
background: var(--accent);
color: #fff;
color: var(--accent);
}
.secondary-link svg {
width: 14px;
height: 14px;
flex-shrink: 0;
.secondary-sep {
color: var(--text-muted);
opacity: 0.4;
font-size: 0.8rem;
}
/* Episodes */
@@ -729,6 +721,26 @@ a:hover {
height: 100%;
}
.diagram-row-compact {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
width: 100%;
}
.diagram-row-compact .diagram-box {
min-width: unset;
padding: 0.5rem 0.75rem;
font-size: 0.7rem;
gap: 0.25rem;
}
.diagram-row-compact .diagram-icon {
width: 20px;
height: 20px;
}
.diagram-arrow {
font-size: 1.5rem;
color: var(--text-muted);
@@ -916,6 +928,21 @@ a:hover {
color: var(--accent);
}
.hiw-cta-support {
display: inline-block;
margin-top: 1.25rem;
color: var(--text-muted);
font-size: 0.85rem;
text-decoration: none;
border-bottom: 1px solid var(--text-muted);
transition: color 0.2s, border-color 0.2s;
}
.hiw-cta-support:hover {
color: var(--accent);
border-color: var(--accent);
}
/* Episode Page */
.ep-header {
max-width: 900px;

View File

@@ -96,6 +96,7 @@
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener">X</a>
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener">Bluesky</a>
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener">Mastodon</a>
<a href="https://primal.net/p/nprofile1qqswsam9cx06j7sxzpl498uquk3kgrwedxtq48j57zxkuj8fs82xtugge0wtg" target="_blank" rel="noopener">Nostr</a>
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
@@ -108,6 +109,7 @@
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
</div>
</div>
<p class="footer-contact">Support the show: <a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Ko-fi</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
<p>&copy; 2026 Luke at the Roost &middot; <a href="/privacy">Privacy Policy</a></p>
</footer>

View File

@@ -94,6 +94,12 @@
</div>
<span>Real Callers</span>
</div>
<div class="diagram-box diagram-accent">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="16" rx="2"/><path d="M2 6l10 7 10-7"/></svg>
</div>
<span>Voicemails</span>
</div>
</div>
<div class="diagram-arrow">&#8595;</div>
<!-- Row 2: Control Room -->
@@ -132,6 +138,18 @@
</div>
<span>Audio Router</span>
</div>
<div class="diagram-box">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
</div>
<span>Phone System</span>
</div>
<div class="diagram-box">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
</div>
<span>Ad Engine</span>
</div>
</div>
<!-- Row 4: Recording -->
<div class="diagram-row">
@@ -187,11 +205,17 @@
</div>
<span>Website</span>
</div>
<div class="diagram-box">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="11" rx="2"/><path d="M7 21h10"/><path d="M12 14v7"/><polygon points="10 8 16 11 10 14 10 8"/></svg>
</div>
<span>Social Clips</span>
</div>
</div>
<div class="diagram-arrow">&#8595;</div>
<!-- Row 7: Distribution -->
<div class="diagram-label">Distribution</div>
<div class="diagram-row diagram-row-split">
<div class="diagram-row-compact">
<div class="diagram-box diagram-accent">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>
@@ -216,6 +240,36 @@
</div>
<span>RSS</span>
</div>
<div class="diagram-box diagram-accent">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm3.1 14.5c-1.7 1-3.8.6-4.8-1.1-1-1.7-.6-3.8 1.1-4.8 1.7-1 3.8-.6 4.8 1.1 1 1.7.5 3.8-1.1 4.8z"/></svg>
</div>
<span>Instagram</span>
</div>
<div class="diagram-box diagram-accent">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
</div>
<span>Facebook</span>
</div>
<div class="diagram-box diagram-accent">
<div class="diagram-icon">
<svg viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C10.945 203.659 1 75.291 1 57.946 1-28.906 76.134-1.612 123.121 33.664z"/></svg>
</div>
<span>Bluesky</span>
</div>
<div class="diagram-box diagram-accent">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054 19.648 19.648 0 0 0 4.636.528c.164 0 .329 0 .494-.002 1.694-.042 3.48-.152 5.12-.554 2.21-.543 4.137-2.186 4.348-4.55.162-1.808.21-3.627.142-5.43-.02-.6-.168-1.874-.168-1.874z"/><path d="M19.903 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192zM9.337 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192z" fill="#fff"/></svg>
</div>
<span>Mastodon</span>
</div>
<div class="diagram-box diagram-accent">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186.31a.27.27 0 0 0-.372 0C8.46 3.487 2.666 9.93 2.666 15.042c0 5.176 4.183 8.958 9.334 8.958s9.334-3.782 9.334-8.958c0-5.112-5.794-11.555-9.148-14.732z"/></svg>
</div>
<span>Nostr</span>
</div>
<div class="diagram-box diagram-accent">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg>
@@ -414,6 +468,32 @@
<div class="hiw-step">
<div class="hiw-step-number">10</div>
<div class="hiw-step-content">
<h3>Automated Social Clips</h3>
<p>No manual editing, no scheduling tools. After each episode, an LLM reads the full transcript and picks the best moments — funny exchanges, wild confessions, heated debates. Each clip is automatically extracted, captioned with word-level timing, and rendered as a vertical video with the show's branding. A second LLM pass writes platform-specific descriptions and hashtags. Then a single script blasts every clip to Instagram Reels, YouTube Shorts, Facebook, Bluesky, and Mastodon simultaneously — six platforms, zero manual work.</p>
<div class="hiw-detail-grid">
<div class="hiw-detail">
<span class="hiw-detail-label">Human Effort</span>
<span class="hiw-detail-value">Zero</span>
</div>
<div class="hiw-detail">
<span class="hiw-detail-label">Video Format</span>
<span class="hiw-detail-value">1080x1920 MP4</span>
</div>
<div class="hiw-detail">
<span class="hiw-detail-label">Captions</span>
<span class="hiw-detail-value">Word-level sync</span>
</div>
<div class="hiw-detail">
<span class="hiw-detail-label">Simultaneous Push</span>
<span class="hiw-detail-value">6 platforms</span>
</div>
</div>
</div>
</div>
<div class="hiw-step">
<div class="hiw-step-number">11</div>
<div class="hiw-step-content">
<h3>Global Distribution</h3>
<p>Episodes are served through a CDN edge network for fast, reliable playback worldwide. The RSS feed is automatically updated and picked up by Spotify, Apple Podcasts, YouTube, and every other podcast app. The website pulls the live feed to show episodes with embedded playback, full transcripts, and chapter navigation — all served through Cloudflare with edge caching. From recording to available on every platform, the whole pipeline is automated end-to-end.</p>
@@ -496,6 +576,7 @@
<div class="hiw-cta-phone">
Or call in live: <strong>208-439-LUKE</strong>
</div>
<a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener" class="hiw-cta-support">Support the show on Ko-fi</a>
</section>
<!-- Footer -->
@@ -508,6 +589,7 @@
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener">X</a>
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener">Bluesky</a>
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener">Mastodon</a>
<a href="https://primal.net/p/nprofile1qqswsam9cx06j7sxzpl498uquk3kgrwedxtq48j57zxkuj8fs82xtugge0wtg" target="_blank" rel="noopener">Nostr</a>
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
@@ -520,6 +602,7 @@
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
</div>
</div>
<p class="footer-contact">Support the show: <a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Ko-fi</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
<p>&copy; 2026 Luke at the Roost &middot; <a href="/privacy">Privacy Policy</a></p>
</footer>

View File

@@ -89,37 +89,35 @@
<div class="off-air-badge" id="off-air-badge">
OFF AIR
</div>
<span class="phone-label">Call in live</span>
<span class="phone-number">208-439-LUKE</span>
<span class="phone-digits">(208-439-5853)</span>
<span class="phone-inline">Call in: <strong>208-439-LUKE</strong></span>
</div>
<div class="subscribe-row">
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="subscribe-btn btn-spotify">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>
Spotify
</a>
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="subscribe-btn btn-apple">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.34 0A5.328 5.328 0 0 0 0 5.34v13.32A5.328 5.328 0 0 0 5.34 24h13.32A5.328 5.328 0 0 0 24 18.66V5.34A5.328 5.328 0 0 0 18.66 0zm6.525 2.568c2.336 0 4.448.902 4.448 3.545 0 1.497-.89 2.67-1.916 3.545-.663.566-.795 .84-.795 1.347 0 .6.397 1.173.894 1.722 1.417 1.564 1.96 2.853 1.96 4.448 0 3.063-2.673 4.257-5.165 4.257-.315 0-.658-.02-.994-.063-1.523-.195-2.86-.9-3.632-.9-.82 0-1.98.623-3.377.87A5.715 5.715 0 0 1 3.15 21.4c-1.27 0-2.1-.96-2.1-2.663 0-1.2.6-2.7 1.845-4.29.63-.81 1.62-1.83 2.91-2.31-.06-.6-.09-1.14-.09-1.62 0-4.28 2.76-7.95 6.15-7.95z"/></svg>
Apple
</a>
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="subscribe-btn btn-youtube">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
YouTube
</a>
<span class="subscribe-label">Listen On</span>
<div class="subscribe-buttons">
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="subscribe-btn">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>
Spotify
</a>
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="subscribe-btn">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.34 0A5.328 5.328 0 0 0 0 5.34v13.32A5.328 5.328 0 0 0 5.34 24h13.32A5.328 5.328 0 0 0 24 18.66V5.34A5.328 5.328 0 0 0 18.66 0zm6.525 2.568c2.336 0 4.448.902 4.448 3.545 0 1.497-.89 2.67-1.916 3.545-.663.566-.795 .84-.795 1.347 0 .6.397 1.173.894 1.722 1.417 1.564 1.96 2.853 1.96 4.448 0 3.063-2.673 4.257-5.165 4.257-.315 0-.658-.02-.994-.063-1.523-.195-2.86-.9-3.632-.9-.82 0-1.98.623-3.377.87A5.715 5.715 0 0 1 3.15 21.4c-1.27 0-2.1-.96-2.1-2.663 0-1.2.6-2.7 1.845-4.29.63-.81 1.62-1.83 2.91-2.31-.06-.6-.09-1.14-.09-1.62 0-4.28 2.76-7.95 6.15-7.95z"/></svg>
Apple
</a>
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="subscribe-btn">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
YouTube
</a>
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener" class="subscribe-btn">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248S0 22.546 0 20.752s1.456-3.248 3.252-3.248 3.251 1.454 3.251 3.248zM.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594C14.524 16.21 7.849 9.636.002 9.473zM.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604C23.834 10.952 13.054.166.006 0z"/></svg>
RSS
</a>
</div>
</div>
<div class="secondary-links">
<a href="/how-it-works" class="secondary-link">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/></svg>
How It Works
</a>
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener" class="secondary-link">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z"/></svg>
Discord
</a>
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener" class="secondary-link">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248S0 22.546 0 20.752s1.456-3.248 3.252-3.248 3.251 1.454 3.251 3.248zM.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594C14.524 16.21 7.849 9.636.002 9.473zM.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604C23.834 10.952 13.054.166.006 0z"/></svg>
RSS
</a>
<a href="/how-it-works" class="secondary-link">How It Works</a>
<span class="secondary-sep">&middot;</span>
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener" class="secondary-link">Discord</a>
<span class="secondary-sep">&middot;</span>
<a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener" class="secondary-link">Support the Show</a>
</div>
</div>
</div>
@@ -217,6 +215,7 @@
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener">X</a>
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener">Bluesky</a>
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener">Mastodon</a>
<a href="https://primal.net/p/nprofile1qqswsam9cx06j7sxzpl498uquk3kgrwedxtq48j57zxkuj8fs82xtugge0wtg" target="_blank" rel="noopener">Nostr</a>
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
@@ -229,6 +228,7 @@
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
</div>
</div>
<p class="footer-contact">Support the show: <a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Ko-fi</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
<p>&copy; 2026 Luke at the Roost &middot; <a href="/privacy">Privacy Policy</a></p>
</footer>

View File

@@ -93,6 +93,7 @@
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener">X</a>
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener">Bluesky</a>
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener">Mastodon</a>
<a href="https://primal.net/p/nprofile1qqswsam9cx06j7sxzpl498uquk3kgrwedxtq48j57zxkuj8fs82xtugge0wtg" target="_blank" rel="noopener">Nostr</a>
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
@@ -105,6 +106,7 @@
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
</div>
</div>
<p class="footer-contact">Support the show: <a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Ko-fi</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
<p>&copy; 2026 Luke at the Roost &middot; <a href="/privacy">Privacy Policy</a></p>
</footer>

View File

@@ -96,4 +96,10 @@
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-13-navigating-life-s-unexpected-turns</loc>
<lastmod>2026-02-16</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
</urlset>

View File

@@ -65,6 +65,7 @@
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener">X</a>
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener">Bluesky</a>
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener">Mastodon</a>
<a href="https://primal.net/p/nprofile1qqswsam9cx06j7sxzpl498uquk3kgrwedxtq48j57zxkuj8fs82xtugge0wtg" target="_blank" rel="noopener">Nostr</a>
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
@@ -77,6 +78,7 @@
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
</div>
</div>
<p class="footer-contact">Support the show: <a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Ko-fi</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
<p>&copy; 2026 Luke at the Roost &middot; <a href="/privacy">Privacy Policy</a></p>
</footer>

View File

@@ -0,0 +1,205 @@
LUKE: All right, welcome back. It's Luke at the Roost. This is the radio show where I take your calls and give you real-world advice. If you'd like to call in, the numbers 208-439-58-3-3. That's 208-439 Luke. And we have a new feature to the show. Now, if you call in and we're not recording, you can leave a voicemail. And if your voicemail is funny enough, I will play it on the next episode. If we all, we all, we are, you all. We are, you all, you can leave a voicemail. If we are, you call in, we are, if you can leave a voicemail. If we are, if you are, if you can leave you can recording and you call in now, you'll be placed into a queue, and I can take your call, and we'll talk to you live on the air. Today is Sunday, February 15th. It's about 10.30, and our phones are already lighting up, as they usually are around now. And first up on the show, we have Phil. Phil, welcome to the show. What would you like to talk about tonight?
PHIL: Oh, man, Luke, thanks for taking my call. So I'm sitting in my truck right now, because Three hours ago, my brother sent me a screenshot of my partner's Tinder profile, and I can't go home yet.
LUKE: Oh, what was the screenshot? Was it a picture you're familiar with?
PHIL: Yeah, it's this photo from last month. We went up to Sedona. I paid for the whole trip, and Marcus bought this ridiculous cowboy hat at some tourist shop. We were laughing about it, you know, like it was this inside joke between us. Tinder photo.
LUKE: So what are you going to do? Do you have a Tinder profile as well? Are you going to match with them?
PHIL: Oh, Jesus. I haven't even thought about that. No, I don't have one. I mean, I thought we were past all that. But that's kind of genius in a horrible way, right? Just swipe right. See what happens?
LUKE: Yeah, maybe you've got something in common and it's worth getting together and having a little date or some type thing.
PHIL: Yeah. Hey, you look familiar. God, that's... I don't know if I'm more pissed off or if I just want to understand what the hell he's thinking. Like we've been together two years. I lost my first husband to cancer six years back, and I really thought Marcus got that... got how big a deal it was for me to do this again.
LUKE: Yeah, that sounds unfortunate. You should probably maybe confront Marcus about that and be like, hey, saw your Tinder profile. What's this about? How long you've been stepping? out, um, then make sure that whatever it is he's doing, he's doing in a protected manner, you know?
PHIL: Yeah, you're right. I mean, that's the thing that's actually making me sick right now. Like, I haven't even let myself go there yet. The health stuff.
LUKE: Well, that's the important stuff. And you might want to get yourself checked out and all that, uh, before anything becomes an issue. But talk to, talk to, talk to your partner. And, uh, maybe you've found, um, you've found, out the hard way that he's not the right partner for you.
PHIL: Yeah. Yeah. I think I already know he's not, you know? Like the second I saw that screenshot, it wasn't even shock. It was just this feeling of, oh, there it is. Like I've been waiting for the other shoe to drop this whole time and now it has. The thing is, I keep thinking about my first husband, Tom.
LUKE: And what happened with your first husband? answer? Did I get that right?
PHIL: Yeah, pancreatic. It was eight months from diagnosis to the end. And the whole time, even when he couldn't get out of bed, even when he was so sick, there was never a question, you know, never a doubt about us. And I guess I thought that's what love was supposed to look like. That's the bar.
LUKE: Hey, man, I'm sorry to hear that. And, uh, you know, some people get that once in their life. Some people get it more than once. Uh, the thing is you've got to be, You got to be all right by yourself and worry about love second. So how are you feeling individually as yourself? Do you need your partner to feel whole or would you be all right on your own?
PHIL: Man, that's the question, isn't it? I don't know. I thought I was good on my own after Tom died. I did the work, you know. Went to the grief group, got back into my photography, managed the bar, saw my friends.
LUKE: sound like it's a good idea to hang out with a dude that is seeing other people on the side without telling you. So it might be time to break up that relationship. I don't know. You should have that conversation with him. And if you've got a strike out on your own again, then do that. Get back to the photography and your friends and move on with your life.
PHIL: You're right. You're absolutely right. I just... I keep thinking about how I'm going to tell my brother
LUKE: Why is your brother in this picture? It sounds like that's none of his business.
PHIL: No, you're right, it's just he's the one who sent me the screenshot. He found Marcus on Tinder and took the screenshot and sent it to me. So now he's going to want to know what I did about it. And Eddie's got this way of making everything feel like a test I'm failing, you know? Like he's keeping score of whether I'm handling my life right.
LUKE: whatever, let him keep score. You don't have to care about what his score is. Just sit down with your partner and talk it out. If you got to break up, break up and move on. And I wish you only happiness going forward. Okay, sir. Thank you for the call.
PHIL: Yeah, yeah, okay. Thanks, Luke.
LUKE: All right. Good luck to you and your brother and your husband or whatever. Hope everything works out there. Looks like next up we've got Lori. Welcome to the show. How are you doing tonight?
LORI: Oh, man, I'm not great, Luke. I've been sitting in my truck at a flying J for 20 minutes trying to figure out if I'm about to blow up my whole life here.
LUKE: Why are you thinking about blowing up your life?
LORI: So my 14-year-old daughter's been playing me and her dad against each other for God knows how long. Telling him, she's at my place on weekends. Telling me she's with him. And actually, she's been out in the desert somewhere with friends doing who the hell knows what. He just called me back about it 20 minutes ago. And here's the thing. I'm hauling refrigerated goods. I'm six days into this route. I've got a load that needs to be in Tucson by tomorrow morning. But I'm four hours from home right now. And I know, I know I need to turn this rig around and deal with this.
LUKE: Well, think this one through. What happens if you do turn the rig around? What are you going to do when you get there? How are you going to deal with it?
LORI: That's the problem, Luke. I don't know. I mean, what am I going to do? Ground her. She's already been sneaking out for weeks, maybe months, show up and yell at her. That's just going to make her better at lying. Her dad and I, we can barely have a conversation without it turning into whose fault everything is.
LUKE: All right? Well, it sounds like that makes your decision, right? If there's no action for you to take if you were to go back, then don't go back. You've got work to do. You've got to take care of your responsibilities to your job too. And then you can deal with this when you're get back. Kids do do this. Kids go out and party in the woods and play their parents off each other. It's a very normal thing for a teenage kid to do. It's not that, not that life-shattering.
LORI: Yeah, but okay, you're right. It's normal, but Luke, she's 14. And I don't even know where she's going or who she's with. Her dad won't tell me the names of these friends because apparently she made him promised not to.
LUKE: Well, it doesn't sound like it really matters what their names are, right? She's out doing her thing. I don't see a problem here. Do your job. Talk to your family when you get home. And if you don't want her out partying at 14 out in the desert, then maybe think about a different profession where you can be around to be involved in her life more than being on the road for six days at a time, you know?
LORI: Wow. Okay. Yeah, no. You're that's exactly what my ex said when I took this job. that I was choosing the road over her.
LUKE: And here's the thing that pisses me off about that. I took this job because of the divorce.
LORI: Hey, I'm not trying to ideologically say that it's right or wrong for you to have taken the job. I don't care. I'm just saying if you're a long-haul trucker and you're gone for weeks at a time, then you're not particularly invested in your teenage daughter's life. And you're only going to show up once in a while to scold her for doing something wrong and acting out direction or guidance from our parent in her life. And that's not unusual. I'm not saying it's even wrong. I mean, you want her to be safe and you want to do this job and both those things can happen, but you need to sit down with her and talk to her about safety, make sure that she's letting you know what's going on because you care. If you do care, if not, then you just want to be controlling and punish her for doing, you know, stuff behind your back, which is, I don't think that's fair.
LUKE: No, you're right. I, God, I do care. I care so much it's making me sick right now sitting here. And you know what the worst part is? My dispatcher, Karen, has been so good to me since the divorce. She worked out this schedule where I could do shorter halls, be home more often, and I still manage to screw it up.
LORI: I don't know if you screwed it up or not, but the fact remains, you're away from home a lot. your daughter's home, and she's out doing stuff. You know, if you're not going to be around, she's going to live her life. And that might not be in a way that you appreciate. But you're making that choice, and it could be the right choice or the wrong choice for you. But you've got to decide. You can't have it both ways.
LUKE: You're absolutely right. I can't. And sitting here trying to figure out if I blow this contract and turn around or keep going, I'm just avoiding the real decision. aren't I? The thing is, Luke, when I lost the house and the divorce, when everything fell apart, this job felt like the one thing I could actually control.
LORI: Well, maybe it is. Maybe the job's a great thing for you and your family and it's providing for you, but you have to then give your daughter the grace to be her own person early because you're not there to give her that traditional upbringing that she otherwise would have got. It doesn't mean she's going to turn into a mom. her in adulthood. She's just going to have a different road. And that can be okay, but you should talk about it.
LUKE: Yeah. Yeah, you're right. I need to actually talk to her, not at her.
LORI: That's right. And it sounds like you've got a job to do. So get back on the road, finish out your obligation. And when you get back, sit her down and have a conversation that's not accusatory and you're not mad at her or punishing her, but talk to her. And let her, know what's going on in your life, why you've made the decisions that you've made, and what you expect out of her, and come to a compromise, an agreement. Talk to her like she's an adult, because at this point, she kind of is.
LUKE: She kind of is. God, that's terrifying. All right. All right. I'm going to finish the route. I'm going to get home Tuesday. And I'm going to sit down with her. Not with her dad there. Not making it this big intervention thing. Just me and her.
LORI: it sounds like a good plan.
LUKE: I hope it all works out. But, you know, I was in a similar situation as a teenager. I was your daughter in this scenario. And I was out with people I shouldn't have been with doing things I shouldn't have done. And there's lots of reasons for that. But it's not because my parents didn't love me. It's not because I was a piece of shit. It's, we're not going to get into it all right now. But over the course of the show, we'll probably learn a little bit more about that. For now, though, we have to go to our sponsor. So please stay tuned for a word from our sponsors. Life is hard. You're listening to a man in an RV talk to strangers at 2 in the morning, so you already know that. That's why we partnered with Better Maybe. Online therapy that's honest about the whole situation. With Better Maybe, you get matched with a licensed therapist within 48 hours. Will they fix your problems? Maybe. That's the whole brand. They're not going to lie to you. Your first session might change your life. It also might just be you staring at the webcam while someone in another time zone nods politely. That's still more than your friends are doing. Better maybe. It's better than nothing. And that's not nothing. Okay. Better maybe. Give them a call. Maybe they can help some of our callers. That's why I'm not. they reached out to us so they could get their product to you. Okay, Roland, Roland, welcome to the show. What's going on tonight, sir?
ROLAND: Oh, man. So I've been working from home for UPS for four years now, routing, logistics, all that. And Friday, they dropped the email, back to the Albuquerque office by March 1st, or I'm out. Three hours of driving every day, Luke, six hours round trip. And the thing is, I finally got my life set up exactly how I wanted it.
LUKE: Well, it sounds like maybe you've got to let that job go then. I mean, a six-hour round-trip commute to work for UPS doesn't sound reasonable to me.
ROLAND: Yeah, but it's 16 years total with the company, you know? The benefits, the retirement, are not starting over at 43, and the pay's solid. But, man, I finally set up my grandfather's old workbench in the garage, been restoring furniture at night, and I'm actually at it. My neighbor Gary caught me sanding at midnight Friday with every light on. asked what was eating me, and I couldn't even explain it to him.
LUKE: Yeah, well, the pay may be solid, but now subtract six hours of commuting expenses and mileage on your vehicle. Retirement doesn't just start over if you go to another company. Like, I assume you've got an IRA or a 401K type situation with them. That's going to roll over into your next position. It's not like you forfeit it. You might forfeit whatever pension it is you got, but you got, what, 45 years more to work? Come on, man. You can't just deal with that for another 40 years because you're holding onto a pension. That's silly. If you've got to let it go, you've got to let it go.
ROLAND: No, you're right. You're right. I know you're right. It's just, okay. So here's the real thing. I spent the first 12 years on the road, delivering packages, breaking my back in the heat. When they finally moved me to remote work during COVID, it felt like I'd made it, you know?
LUKE: I do know. I remember back when I demanded to work remote, I would not go into an office anymore. And everybody told me that was a mistake and that you can't do that. This was pre-COVID. And I said, I can do whatever I want. You know, I'm going to do what I'm going to do. And I don't have to do anything I don't want to do. And I don't want to go into an office, so I'm not going to do it. And then I never went back into an office again. You can absolutely do that. There's plenty of ways to make money. You don't have to work for UPS. You can start your own company. You can start your own company. find another job that offers you remote work. You can, I don't know, play the stock market. There's plenty of ways that people make money without going in an office. And if you really don't want to go into an office, don't.
ROLAND: You know what? You're making it sound simpler than it feels, but okay, I've been reading about these CEOs running remote first companies. No plans to bring people back. And I keep thinking, why can't UPS figure this out? I've been more productive at home, six new hires over Zoom last year, never missed a deadline. But I think what's really getting me is,
LUKE: Well, before you get to that, I think a lot of companies and a lot of research has proven that people are generally very productive at home, and in a lot of cases more so than an office.
TERRY: Oh, man, Luke. So my best friend since third grade just told me she's been sleeping with my ex-husband since literally the week our divorce was finalized. Like, Michelle was at my wedding. She helped me move my stuff out of the house six months ago. she's been with David this whole time. And when I got upset about it tonight, because, yeah, I'm upset, she hit me with, it's been six months, you should be over it. Like I'm being dramatic for caring that she's been lying to my face for half a year while I'm crying to her about the divorce.
LUKE: Yeah, that's messed up. I think she's not your friend, and it's time to let go of that relationship.
TERRY: I mean, yeah, you're right. I know you're right. But Luke, 30 years. 30 years. We learned to drive together. We got matching tattoos when we turned 20. One, stupid little stars on our ankles. Her mom was like my second mom growing up. And it's not even just about David, you know?
LUKE: No, I don't know, but the whole David thing tells me that this chick needs to go. She doesn't care about you. Whether you've known her for 30 years or 300 years, Fuck this chick.
TERRY: You're not wrong. God, you're not wrong. It's just... Okay, so here's the thing that's really messing with me. I'm sitting here in my uncle's laundromat, at 11 o'clock on a Sunday night, with a warm beer, and I'm realizing I'm more hurt about Michelle than I ever was about David. Like the divorce sucked, but I saw that coming for a while, you know? We've been going through the motions for like two years.
LUKE: Yeah, that's a hard thing. I understand. And, you know, you've got that much history with somebody. It sucks to lose it, but it's, uh, it sounds like it's not you that has made this decision. And this person isn't somebody that's got your back that you can trust. So if you thought you had a great friend, maybe you did it once, but people change. And it sounds like she changed. And maybe some day she'll change again. But for now, let that chick go. She sucks.
TERRY: Yeah. Yeah. Yeah. She does suck. You know what the worst part is? I called her first when I found out David was seeing someone. Like two weeks ago, I saw his truck at the Applebee's with some woman, and I was all worked up about it, called Michelle literally crying in the parking lot. And she was like, oh, honey, you got to let him go. He's moved on. The whole time knowing it was her. That's that sociopath behavior, right?
LUKE: It sure is. And it's not what you would expect about a friend of 30 years. So my advice to you is just stop talking to that chick. Let her go. Let her have the dude. And, uh, move on with your life.
TERRY: You're right. I know you're right. It's just, God. It's pathetic, but I'm sitting here thinking about who I'm going to call now when stuff goes wrong. You know, like she was that person.
LUKE: Well, it sounds like you're going to call Luke at the Roost, the, uh, call in radio show where we help you out with your real world problems.
TERRY: Ha. Yeah. Well, you're doing better than she did tonight. That's for sure. I just... Okay. Real talk, though. Am I crazy for still being hurt about David?
LUKE: No, you're not crazy.
LUKE: You're hurt when you're hurt, and you've got good reason to be hurt here, and you've got some grief going on, and it's big life changes, and there's lots of reasons to be hurt. But you don't have to continue to stay that way. If you don't want to, you can decide to not be hurt and move on. And I think that's what you need to. to do. It's easier said than done, but it can be done. So just every day, wake up and be grateful to be here and say, hey, you know, I don't have the life that I had yesterday, but I've got the life that I have today, and it can still be pretty fucking cool.
TERRY: You know what? You're right. And honestly, and this is going to sound terrible. But I think I've been more hung up on the idea of David than actual David for a while now. Like we got married at 23, Luke. 23.
LUKE: Yep, that happens. That is young, and it explains a lot. So, um, you know, I wish you the best of luck. We're going to move on on the show now, unless you have some other point to bring up. I'll give you one more chance to respond. But otherwise, I'm going to say, uh, let go with the chick, let go to the dude, move on with your life, and everything's going to be fine.
TERRY: No, you're good. I appreciate you letting me vent. And hey, I heard Roland, earlier? The UPS guy?
LUKE: Yeah, what about him?
TERRY: You got a job for Roland? No, but his wife definitely sucks too. That whole thing about her wanting him out of the house? Come on. That man needs to check the credit card statements.
LUKE: That is absolutely true. I didn't want to say that to him at the time, but he absolutely should check the credit card statements. And maybe the little internal camera that he's got in the fucking teddy bear And now, now folks, you know what? Actually, she's already gone, but Terry, that was the smartest thing you sent that whole call. All right. Now it's time for a word from our sponsors. I'm not saying that for sympathy. I'm saying it because Pillow Forever asked me to establish a before state, and that's mine. Pillow Forever is a memory foam pillow that remembers your head shapes so you don't have to. It's got cooling gel, bamboo fiber, and a 30-night risk-free trial, which means you can sleep on it for a month and then send it back, and someone in a warehouse has to deal with that. Every pillow forever comes to. in a box that's too small, which is part of the experience. You open it and it slowly expands like a major documentary. My dog tried to fight it. Pillow Forever. You deserve better than a horse blanket. Okay, thanks to Pillow Forever. They are a proud sponsor of the Luke at the Roo Show, and we appreciate them. Now let's get to, uh, let's get back to the phones. Chester, Chester, welcome to the show. What's on your mind, sir?
CHESTER: Luke, hey, so I almost killed my kid this morning. Not on purpose, I mean. I didn't. I showed up to get her from her ex-wife's place, custody Sunday, and the second I rolled down the window, I could smell the whiskey coming off me. Hands still shaking. I'd been drinking until like four in the morning, and it was eight o'clock. And I'm sitting there with the engine running, thinking, well, I'm sitting there with the engine running, thinking, well, What the hell am I doing? She's seven. Her name's Daisy.
LUKE: Oh man, yeah. You can't, you can't do that, man. If you've got a drinking problem, then you're going to have to take, you're going to have to take care of that because you could kill your kid and or you could kill someone else's kid and it's really not okay. It's, it's irresponsible and it's not good for anybody.
CHESTER: No, you're right. You're absolutely right. I didn't drive. I mean, I sat there for maybe 30 seconds. And then I shut the truck off and I called her mom and told her I was sick, food poisoning or something, which is bullshit. She probably knew.
LUKE: How long have you known you've had a problem with the drinking?
CHESTER: I mean, that's the thing, Luke. I don't drink every day. I work at Desert Star Pond, been there six years. I show up on time. I do my job. It's just when I get home and Daisy's not there. When it's those empty nights, I'll start with one beer. And then it's three I'm watching Civil War documentaries with a bottle of Jim Beam. And I can tell you the exact date of Antietam, but I can't tell you when it got like this.
LUKE: Well, are you, uh, are you tired of it yet? Or it sounds like maybe this is a moment of clarity where you're recognizing the situation and, and thinking about taking accountability for it. Is that true?
CHESTER: Yeah. Yeah. Yeah. I think. I mean, my dad, Big Jim, he ran cattle for years. Never missed a day. Never touched the stuff.
LUKE: Well, you know, they have support group meetings. It can help you out in these situations. You can go to after work. Lots of cool people there that are trying to live sober, not pick up the drink and end up in situations like you're finding yourself in now. It is possible. You don't have to get drunk by yourself at night watching Civil War documentaries. You can just watch the Civil War documentary without they're getting drunk bit. then when you get up in the morning and pick up your kid, you're not going to be drunk.
CHESTER: I know. You're right. It's just, the house is so damn quiet, you know? And I keep thinking about what kind of example I'm setting.
LUKE: Well, it's good that you figure it out now when you're at a spot where you can still do something about it. And what I recommend is when you get home, next time you get home, do something else. Instead of cracking that beer, just do anything else. You can play a video game or you could, uh, go for a walk or you could read or play on the internet, whatever it is, just break that habit of having that first beer. Because if you don't pick up the first one, you're not going to end up drunk. Isn't it funny how that works? If you don't pick up the first one, you're not going to end up drunk.
CHESTER: Yeah, yeah, that makes sense. I heard Lori earlier, the trucker lady with the daughter sneaking out, and I kept thinking at least she's got a reason she's not around, you know?
LUKE: No, what do you mean by that?
CHESTER: I mean, she's working. She's on the road providing for her kid. Me, I'm just, I'm home and Daisy's at her moms. And I'm choosing to sit there with a bottle instead of, I don't know, calling her reading her a bedtime story over the phone. Something.
LUKE: Well, yeah. And, uh, you know, sometimes you just have to say that to a radio host at midnight to, uh, to hear yourself say it. So hopefully you can snap out of your, uh, your, your situation. Don't pick up the beer next time. And instead, call your daughter and read her a story.
CHESTER: You're right. I actually. I got this book about Gettysburg I was going to show her. She's been asking about it because we drove through Pennsylvania last summer. And she saw the signs. Smart kid. Smarter than me, that's for sure.
LUKE: Well, you sound pretty smart today. You know you get a problem and you're going to do stuff to take care of it. So if you need help, then I recommend that you find your your local neighborhood at AA or NA meetings, depending on what type of situation you're dealing with. And just go check it out. And have an open mind and maybe you find a new way of life. Okay, Dolores. Delores, welcome to the radio show. How can we help?
DELORES: Hey, Luke. Yeah. So, okay. I got recognized today at an open house and I kind of panicked. And now sitting in a circle K parking lot feeling like a complete idiot.
LUKE: Well, all of us get recognized several times a day. So why is it, what is it about this particular recognition that's got you up in arms?
DELORES: Because I've been going to open houses every weekend for like eight months and I'm not actually buying anything. This realtor, Patricia, she remembered me from a showing last spring, and she just asked me you actually planning to buy a house and I froze. I told her my financing fell through, but that's not even true because I've never applied for a loan in the first place.
LUKE: Okay. Next up on the radio show, we have Marvin. Marvin, thanks for calling in. What's happening tonight?
MARVIN: Hey, Luke. Yeah, thanks for taking the call. So I got home for my shift tonight and there's this cardboard box sitting on my porch. No note, nothing. And it's full of stuff from my dad's house. Stuff I haven't seen since he died 11 years ago. His Bolo tie. This pocket knife I thought I lost senior year. Photos of my uncle's old place.
LUKE: What's a Bolo tie?
MARVIN: Oh, it's that Western Thai thing. You know, the braided leather cord with the silver clasp that slides up and down. My dad wore one to every wedding, every funeral, every time he needed to look respectable. His had this. This turquoise stone in the middle, real heavy.
LUKE: Okay, well, that's cool. Somebody dropped off some of your dead dad's belongings, and what are they bringing up inside? They bring it up memories? Is this got you upset? Are you concerned? Like, what's, what feeling is this bringing up inside of you?
MARVIN: I mean, yeah, memories. But more than that, I'm sitting here like, who the hell had this stuff? my dad's been gone 11 years. Where has this box been? And why now? Why tonight? What's in the box? Why no explanation? Like, someone just decided, oh, time for Marvin to deal with this and left it there like a package from Amazon. And honestly, I opened it and just...
LUKE: All right. Well, congratulations for, uh, the... safe return of your dad's belongings. I hope that you wear them in good health. Next on the radio show, oh, before, no, oh, Luke, you almost, come on, come on, I'm fucking amateur here. It is time for a word from our sponsors. Look, I'm not a financial advisor. I'm a guy with a microphone and a dog. But the folks at Crypto know asked me to tell you about their new decentralized investment platform, and I to read this part. Past performance does not guarantee future results. This is not financial advice. And if you invest your rent money, you deserve exactly what happens next. Crypto No lets you trade over 400 digital currencies, including three that were invented this morning, and one that's just a picture of my dog. The app features a real-time portfolio tracker with a built-in panic button that just plays ocean sounds when your balance drops. Crypto No! Fortune favors the bold, but it does not return their calls. Okay, thanks to Crypto No for sponsoring the show today. I think we're probably running a little bit late. These have been some decent calls. They're going a little long. So I'm just going to take one more for tonight's show. And next up to the line, we have Francine. Francine, you're going to be our last caller of the night.
LUKE: What's on your mind?
CALLER: Luke, I just had a deer on the 11, and it's still alive, and I don't know what the hell I'm playing. supposed to do about it. I've been sitting here with my hazards on for like 20 minutes. Just, it's looking at me. When if its legs is completely shattered, it's on the shoulder. And I can't tell if I'm supposed to call someone, or if I'm supposed to, I don't know, put it out of its misery myself? Which I don't even know how I do that. I'm a nurse. I work on people, not my ex would have known. He grew up out here, hunted with his dad. He would have just handled it.
LUKE: Yeah, that sounds like a. less than ideal situation. I would call the police first, call 911, and let them know that there's been an accident and there's an animal, I assume, still in the road. If you have a gun, then I think you know what you have to do. If you don't have a gun, I would probably refrain from doing anything hasty with like a tire iron or a rock.
CALLER: No, I don't have a gun. It's off the road. It's on the shoulder. So nobody's going to hit it. I just, I'll call. I didn't know if this was even a 911 thing or if there was like animal control or something, but out here at midnight on a Sunday, I guess there's not exactly a whole directory of options. The stupid thing is, I've been driving the same loop between Deming and Los Cruces for six months now. Three different clinics, and this is the first time I've hit anything.
LUKE: Well, you know what I always say? 911 was an inside job. So I don't actually know who you're supposed to call in this situation. I would call 911 and ask the them and they'll let you know or send out animal control or whoever comes out of the sheriff's office. Who knows out here especially? But, you know, it happens. There's wild animals out here. They run in the road. Sometimes we hit them. It's probably not your fault unless you were playing with your phone, and in which case it was your fault. And just do the best you can do. It's unfortunate that the animal's suffering right now, but if you don't have a way to humanely end it, then you got to just get somebody out there as soon. as you can.
CALLER: I wasn't on my phone. I was just tired. Just came off a double. Wasn't paying attention like I should have been. You're right, though. I'll call.
LUKE: All right. Well, get off the line with me and get on the line with 911. And that is the conclusion of our show for tonight. Thanks everybody for tuning in, and we'll talk to you again tomorrow. Thank you.

7
website/voicemail.xml Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="woman">Luke at the Roost is off the air right now. Leave a message after the beep and we may play it on the next show!</Say>
<Record maxLength="120" action="https://radioshow.macneilmediagroup.com/api/signalwire/voicemail-complete" playBeep="true" />
<Say voice="woman">Thank you for calling. Goodbye!</Say>
<Hangup/>
</Response>