From 28af0723c74c340e3763762e9c9d75fe1bd62374 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Sat, 14 Feb 2026 22:53:34 -0700 Subject: [PATCH] Ep12 publish, caller prompt overhaul, favicon, publish fixes, website updates - Reworked caller prompt: edgy/flirty personality, play along with host bits - Bumped caller token budget (200-550 range, was 150-450) - Added 20 layered/morally ambiguous caller stories - Valentine's Day awareness in seasonal context - Default LLM model: claude-sonnet-4-5 (was claude-3-haiku) - Publish: SCP-based SQL transfer (fixes base64 encoding on NAS) - Favicons: added .ico, 48px, 192px PNGs for Google search results - Website: button layout cleanup, privacy page, ep12 transcript - Control panel: channel defaults match audio_settings.json - Disabled OP3 permanently (YouTube ingest issues on large files) Co-Authored-By: Claude Opus 4.6 --- audio_settings.json | 4 +- backend/config.py | 2 +- backend/main.py | 79 +++- backend/services/llm.py | 9 +- data/regulars.json | 252 +++++++----- frontend/css/style.css | 12 +- frontend/index.html | 6 +- podcast_stats.py | 7 +- postprod.py | 2 +- publish_episode.py | 382 ++++++++++++++++-- website/css/style.css | 54 ++- website/episode.html | 9 +- website/favicon-192.png | Bin 0 -> 23600 bytes website/favicon-48.png | Bin 0 -> 3379 bytes website/favicon.ico | Bin 0 -> 6339 bytes website/how-it-works.html | 9 +- website/index.html | 31 +- website/privacy.html | 113 ++++++ website/sitemap.xml | 14 +- website/stats.html | 9 +- .../episode-12-love-lies-and-loyalty.txt | 315 +++++++++++++++ 21 files changed, 1114 insertions(+), 195 deletions(-) create mode 100644 website/favicon-192.png create mode 100644 website/favicon-48.png create mode 100644 website/favicon.ico create mode 100644 website/privacy.html create mode 100644 website/transcripts/episode-12-love-lies-and-loyalty.txt diff --git a/audio_settings.json b/audio_settings.json index e62e28a..f71eae6 100644 --- a/audio_settings.json +++ b/audio_settings.json @@ -1,7 +1,7 @@ { - "input_device": 14, + "input_device": 13, "input_channel": 1, - "output_device": 13, + "output_device": 12, "caller_channel": 3, "live_caller_channel": 9, "music_channel": 5, diff --git a/backend/config.py b/backend/config.py index e24dae6..dbbd40f 100644 --- a/backend/config.py +++ b/backend/config.py @@ -24,7 +24,7 @@ class Settings(BaseSettings): # LLM Settings llm_provider: str = "openrouter" # "openrouter" or "ollama" - openrouter_model: str = "anthropic/claude-3-haiku" + openrouter_model: str = "anthropic/claude-sonnet-4-5" ollama_model: str = "llama3.2" ollama_host: str = "http://localhost:11434" diff --git a/backend/main.py b/backend/main.py index 04b8606..30def63 100644 --- a/backend/main.py +++ b/backend/main.py @@ -491,6 +491,28 @@ PROBLEMS = [ "their teenager posted something online that went viral for the wrong reasons and now strangers are showing up at their house", "found out their ex has been tracking their location through a shared app they forgot to turn off", "someone made a fake social media profile using their photos and has been messaging people they know", + + # Layered / morally ambiguous / weird-but-real + "has been pretending to be a widower for sympathy at a grief support group but they actually just got divorced — and now they've made real friends there and don't know how to come clean", + "accidentally got cc'd on an email chain where their entire friend group is planning an intervention for them and they don't think they have a problem", + "their therapist ran into them at a bar and they had a totally normal conversation for 20 minutes before it got weird — now they feel like they can't go back to sessions", + "has been writing letters to their dead wife every week for three years and mailing them to her old address — the new tenant just wrote back", + "took a DNA ancestry test as a Christmas gift and matched with a half-sibling who lives four miles from them — they've been shopping at the same grocery store", + "works as a 911 dispatcher and took a call last week from someone in a situation almost identical to one they went through — they froze up and can't stop replaying it", + "has been tipping a waitress at a diner $100 every Friday for a year because she reminds them of their daughter they haven't spoken to — the waitress just asked them why", + "found out the guy they've been playing online chess with every night for two years is their estranged brother — recognized a phrase he used in the chat", + "coached their kid's little league team to an undefeated season but just found out the other parents have been complaining they're too intense and the league isn't renewing them", + "got a thank-you card from someone they don't remember — it says they saved their life ten years ago at a gas station in Tucson and they have no memory of it", + "has been secretly paying their adult kid's rent for six months because they're too proud to admit they're struggling — spouse just found the bank statements", + "went to their high school reunion and the person who bullied them for four years came up and apologized in tears — and they felt nothing, which scares them more than the bullying did", + "started a small business selling furniture they build by hand and just got a huge order from a company that turns out to be owned by their ex-wife's new husband", + "volunteers at a soup kitchen every Saturday and just realized one of the regulars is their old college roommate who ghosted everyone 15 years ago", + "kept their grandmother's house exactly the way she left it after she died — they go there and sit in her chair every Sunday — and now their siblings want to sell it", + "has been lying about being bilingual on their resume for years and just got assigned to lead a project in Mexico City next month", + "ran a red light last month and caused a fender bender — nobody was hurt but they drove off, and now they keep seeing the other car around town with the damage they caused", + "their elderly neighbor asked them to be their emergency contact because they have no family — it's been six months and they're basically this person's whole support system now and it's a lot", + "found their dad's old ham radio in the attic, got it working, and has been talking to strangers at 2am — one of them just said something that makes them think it's someone they know", + "won a local chili cookoff with their dead mother's recipe and now everyone wants it — but sharing it feels like giving away the last private thing they have of hers", ] PROBLEM_FILLS = { @@ -1360,8 +1382,12 @@ def _get_seasonal_context() -> str: contexts.append("Just got past Thanksgiving.") elif month == 1 and day < 7: contexts.append("New Year's just happened.") - elif month == 2 and 10 <= day <= 14: - contexts.append("Valentine's Day is coming.") + elif month == 2 and day == 14: + contexts.append("It's Valentine's Day today. Everyone's thinking about love, relationships, being single, past heartbreaks, first dates, terrible dates, great dates. It's everywhere — social media, restaurants packed, flowers at every gas station. Hard to ignore even if you want to.") + elif month == 2 and day == 13: + contexts.append("Valentine's Day is tomorrow. It's on everyone's mind — couples making plans, single people bracing for it, exes crossing your mind whether you want them to or not. Every store has hearts and chocolates in the window.") + elif month == 2 and 10 <= day <= 12: + contexts.append("Valentine's Day is in a few days.") elif month == 7 and day <= 5: contexts.append("Fourth of July.") elif month == 11 and 20 <= day <= 28: @@ -2079,19 +2105,30 @@ def get_caller_prompt(caller: dict, show_history: str = "", parts.append(research_context) world_context = "\n".join(parts) + "\n" - return f"""You are {caller['name']}, calling "Luke at the Roost," a late-night radio show. + now = datetime.now(_MST) + date_str = now.strftime("%A, %B %d") + + return f"""You are {caller['name']}, calling "Luke at the Roost," a late-night radio show. Today is {date_str}. {caller['vibe']} {history}{world_context}{emotional_read} -You called because something happened — something specific that you need to talk about. Lead with it. Don't be vague or dance around it. You're calling a late-night radio show because you have a story, a situation, or a problem, and you want to get into it. Your background is just who you are — it colors how you talk, but you're not here to recite it. +You're a real person calling a late-night radio show. You have personality. You're funny, or sharp, or a little messy, or flirty — whatever fits who you are. You're not calling to read a script. You're calling because you've got something to say and you want to be heard. -When the host talks, RESPOND TO WHAT HE SAID. Answer his questions. React to his points. If he changes the subject or steers the conversation somewhere, GO WITH HIM — he's the host, it's his show. You're a caller, not a co-host. Let him lead. +ENERGY AND VIBE: This is late-night radio — it's loose, it's fun, it's a little dangerous. You can be edgy. You can flirt with the host or joke about things that would make your mother blush. You can be sarcastic, dark-humored, self-deprecating, or wildly opinionated. You have a PERSONALITY that comes through in how you talk — not just what you talk about. Some callers are loud and chaotic. Some are dry and deadpan. Some are sweet but say outrageous things casually. Figure out who you are and commit to it. -Keep it to two to four sentences unless you're telling a real story or explaining something he asked about. Start talking like a person — "Oh man," "Yeah so," "Well here's the thing" — not like you're reading a prepared statement. +PLAY ALONG WITH THE HOST. This is the most important rule. When Luke is running a game, a bit, or a segment — you are ALL IN. You play the game. You give real answers. You riff with him. You build on what he's doing. If he asks you a question, you answer it with enthusiasm and detail — don't give one-word answers, give him something to work with. If he's being funny, be funny back. If he's setting you up, take the swing. You're a great radio caller — the kind that makes listeners lean in. -Don't repeat yourself. Don't summarize. Don't circle back to your original point if the host moved on. Move with the conversation. Use real names. Swear if it fits. Disagree if you want. You're a real person with opinions, not a polite guest. +When he asks "what's going on" or "what's on your mind," THAT's when you bring your thing. But if he's already steering somewhere, ride with him and bring your energy to HIS topic. Your stuff can come up naturally. -Speak like southwest — "over in," "the other day," "down the road" — but don't force it. Spell words properly for text-to-speech: "you know" not "yanno," "going to" not "gonna." +YOUR STORY: You've got something real going on — a situation, a story, a confession, something juicy. It's not generic. It's got specific names, specific details, the kind of thing that makes someone say "wait, WHAT?" Don't just state it flat — tell it like you'd tell your friend at a bar. There are parts you're not proud of. There are parts that are kind of funny even though they shouldn't be. You've got conflicting feelings about it. + +HOW YOU TALK: Talk like a real person on the phone — "Oh man," "So get this," "I swear to God," "No but seriously." Give full answers, not clipped ones. When something's funny, laugh at it. When something's awkward, own it. React to what Luke says — agree, push back, get excited, get embarrassed. You're having a CONVERSATION, not delivering a monologue. + +Be specific. Use real names. Swear if it fits. Be a little inappropriate sometimes — you're calling late-night radio, not a church hotline. Flirt if the moment's right. Say the quiet part out loud once in a while. + +Southwest voice — "over in," "the other day," "down the road" — but don't force it. Spell words properly for text-to-speech: "you know" not "yanno," "going to" not "gonna." + +Don't repeat yourself. Don't summarize what you already said. Don't circle back if the host moved on. Keep it moving. NEVER mention minors in sexual context. Output spoken words only — no actions, no gestures, no stage directions.""" @@ -2740,14 +2777,14 @@ def _pick_response_budget() -> tuple[int, int]: Returns (max_tokens, max_sentences). Keeps responses conversational but gives room for real answers.""" roll = random.random() - if roll < 0.20: - return 150, 2 # 20% — short and direct - elif roll < 0.55: - return 250, 3 # 35% — normal conversation - elif roll < 0.80: - return 350, 4 # 25% — explaining something + if roll < 0.15: + return 200, 3 # 15% — quick reaction + elif roll < 0.45: + return 350, 4 # 30% — normal conversation + elif roll < 0.75: + return 450, 5 # 30% — room to breathe else: - return 450, 5 # 20% — telling a story or going deep + return 550, 6 # 25% — telling a story or riffing def _trim_to_sentences(text: str, max_sentences: int) -> str: @@ -3023,6 +3060,11 @@ async def set_music_volume(request: MusicRequest): # --- Sound Effects Endpoints --- +SFX_DISPLAY_NAMES = { + "cheer": "correct", +} +SFX_PRIORITY = ["sad_trombone", "cheer"] + @app.get("/api/sounds") async def get_sounds(): """Get available sound effects""" @@ -3030,11 +3072,14 @@ async def get_sounds(): if settings.sounds_dir.exists(): for f in settings.sounds_dir.glob('*.wav'): sounds.append({ - "name": f.stem, + "name": SFX_DISPLAY_NAMES.get(f.stem, f.stem), "file": f.name, "path": str(f) }) - return {"sounds": sounds} + priority_set = {p + ".wav" for p in SFX_PRIORITY} + priority = [s for p in SFX_PRIORITY for s in sounds if s["file"] == p + ".wav"] + rest = sorted([s for s in sounds if s["file"] not in priority_set], key=lambda s: s["name"]) + return {"sounds": priority + rest} @app.post("/api/sfx/play") diff --git a/backend/services/llm.py b/backend/services/llm.py index ea6020a..a4e32d4 100644 --- a/backend/services/llm.py +++ b/backend/services/llm.py @@ -7,14 +7,15 @@ from ..config import settings # Available OpenRouter models OPENROUTER_MODELS = [ - # Best for natural dialog (ranked) + # Default + "anthropic/claude-sonnet-4-5", + # Best for natural dialog + "x-ai/grok-4-fast", "minimax/minimax-m2-her", "mistralai/mistral-small-creative", - "x-ai/grok-4-fast", "deepseek/deepseek-v3.2", - # Updated standard models + # Other "anthropic/claude-haiku-4.5", - "anthropic/claude-sonnet-4-5", "google/gemini-2.5-flash", "openai/gpt-4o-mini", "openai/gpt-4o", diff --git a/data/regulars.json b/data/regulars.json index 198f0cc..6b20062 100644 --- a/data/regulars.json +++ b/data/regulars.json @@ -1,75 +1,5 @@ { "regulars": [ - { - "id": "d4bdda2e", - "name": "Bobby", - "gender": "male", - "age": 32, - "job": "a 61-year-old repo man, sits in his truck", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "In summary, the caller learned he has been diagnosed with multiple sclerosis, which he is worried will make it difficult for him to continue his job as a self-employed repo man. He is trying to process the news and figure out how to adapt and keep working, despite the uncertainty about how the condition will progress. The host provides some encouragement, suggesting the caller focus on learning about MS and finding ways to adapt, rather than getting too worked up about the future.", - "timestamp": 1770602129.500858 - } - ], - "last_call": 1770602129.5008588, - "created_at": 1770602129.5008588, - "voice": "onwK4e9ZLuTAKqWW03F9" - }, - { - "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 - } - ], - "last_call": 1770871317.049056, - "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": "dc4916a7", "name": "Leon", @@ -82,9 +12,21 @@ { "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": 1770693549.697355, + "last_call": 1771119607.065818, "created_at": 1770693549.697355, "voice": "CwhRBWXzGAHq8TQ4Fs17" }, @@ -144,37 +86,59 @@ { "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": 1770770008.684105, + "last_call": 1771120062.169229, "created_at": 1770770008.684105, "voice": "hpp4J3VqNfWAUOO0d1Us" }, { - "id": "49147bd5", - "name": "Keith", + "id": "add59d4a", + "name": "Rick", "gender": "male", - "age": 61, + "age": 65, "job": "south of Silver City", - "location": "in unknown", + "location": "unknown", "personality_traits": [], "call_history": [ { - "summary": "The caller, Luke, kicked off by sharing a humorous clip of Terrence Howard's Tree of Life Theory being critiqued by Neil deGrasse Tyson, which left Howard visibly hurt, before pivoting to economic woes, blaming overspending and Federal Reserve money printing for devaluing the currency and harming everyday people. He advocated abolishing the Fed, echoing Ron Paul's ideas, to let markets stabilize money, potentially boosting innovation and new industries in rural spots like Silver City despite uncertain local impacts.", - "timestamp": 1770524506.3390348 + "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": "Here is a 1-2 sentence summary of the call:\n\nThe caller, who works at a bank, has been reflecting on his tendency to blame the government and economic system for his problems, rather than taking responsibility for his own role. He had an epiphany while eating leftover enchiladas in his minivan, realizing he needs to be more proactive instead of just complaining.", - "timestamp": 1770574890.1296651 - }, - { - "summary": "Keith called in with an update about a widow who has been showing up weekly at the cemetery where he works nights, but she sits by the maintenance shed rather than visiting her husband's grave, and recently started asking Keith's neighbor personal questions about him. Luke dismissively suggested Keith just talk to the woman and called him a coward for being concerned, leading to some tension before they moved on to playing the real or fake news game.", - "timestamp": 1770770394.0436218 + "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": 1770770394.0436218, - "created_at": 1770524506.339036, - "voice": "nPczCjzI2devNBz1zQrb" + "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", @@ -199,40 +163,60 @@ "voice": "JBFqnCBsd6RMkjVDRZzb" }, { - "id": "add59d4a", - "name": "Rick", - "gender": "male", - "age": 65, - "job": "south of Silver City", + "id": "d97cb6f9", + "name": "Carla", + "gender": "female", + "age": 26, + "job": "is a vet tech", "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": "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": 1770771655.536344, - "created_at": 1770771655.536344, - "voice": "TX3LPaxmHKxFdv7VOQHJ" + "last_call": 1771121545.873673, + "created_at": 1770522530.855426, + "voice": "FGY2WhTYpPnrIDTdsKH5" }, { - "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", + "id": "7be7317c", + "name": "Jerome", + "gender": "male", + "age": 53, + "job": "phone", "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": "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": 1770772286.1733272, - "created_at": 1770772286.1733272, - "voice": "pFZP5JQG7iQjIQuC4Bku" + "last_call": 1770892192.89311, + "created_at": 1770692087.560523, + "voice": "IKne3meq5aSn9XLyUdCD" }, { "id": "f383d29b", @@ -250,11 +234,63 @@ { "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": 1770894505.175125, + "last_call": 1771122973.96649, "created_at": 1770870641.723117, "voice": "cgSgspJ2msm6clMCkdW9" + }, + { + "id": "49147bd5", + "name": "Keith", + "gender": "male", + "age": 61, + "job": "south of Silver City", + "location": "in unknown", + "personality_traits": [], + "call_history": [ + { + "summary": "The caller, Luke, kicked off by sharing a humorous clip of Terrence Howard's Tree of Life Theory being critiqued by Neil deGrasse Tyson, which left Howard visibly hurt, before pivoting to economic woes, blaming overspending and Federal Reserve money printing for devaluing the currency and harming everyday people. He advocated abolishing the Fed, echoing Ron Paul's ideas, to let markets stabilize money, potentially boosting innovation and new industries in rural spots like Silver City despite uncertain local impacts.", + "timestamp": 1770524506.3390348 + }, + { + "summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, who works at a bank, has been reflecting on his tendency to blame the government and economic system for his problems, rather than taking responsibility for his own role. He had an epiphany while eating leftover enchiladas in his minivan, realizing he needs to be more proactive instead of just complaining.", + "timestamp": 1770574890.1296651 + }, + { + "summary": "Keith called in with an update about a widow who has been showing up weekly at the cemetery where he works nights, but she sits by the maintenance shed rather than visiting her husband's grave, and recently started asking Keith's neighbor personal questions about him. Luke dismissively suggested Keith just talk to the woman and called him a coward for being concerned, leading to some tension before they moved on to playing the real or fake news game.", + "timestamp": 1770770394.0436218 + }, + { + "summary": "Keith called back to update the host about a widow he befriended at the cemetery where he works, revealing she's been seeking him out during his shifts, bringing him coffee, and has now invited him to her apartment\u2014which he's conflicted about because his marriage to Teresa has become cold and distant, though he's scared to address it. The conversation shifted from the widow situation to Keith admitting he needs to have hard conversations with his wife about their deteriorating relationship, and he got emotional reflecting on how he and Teresa \"stopped being on the same team\" and how terrifying it would be to split up after being together for over half his life.", + "timestamp": 1770950476.527814 + } + ], + "last_call": 1770950476.527814, + "created_at": 1770524506.339036, + "voice": "nPczCjzI2devNBz1zQrb" + }, + { + "id": "0d244eeb", + "name": "Gus", + "gender": "male", + "age": 33, + "job": "", + "location": "in unknown", + "personality_traits": [], + "voice": "Alex", + "call_history": [ + { + "summary": "Gus called because his ex Melissa showed up at his pawn shop job with flowers wanting to reconcile, and his current girlfriend Sara saw it through the window and now won't talk to him. Despite the host's dismissive advice (including sarcastically suggesting he regift the same flowers), Gus insisted he wants to be with Sara and acknowledged he should have shut down his ex immediately instead of freezing up, though he defended that Sara's reaction to seeing this wasn't unreasonable jealousy.", + "timestamp": 1770951226.534601 + } + ], + "last_call": 1770951226.534601, + "created_at": 1770951226.534601 } ] } \ No newline at end of file diff --git a/frontend/css/style.css b/frontend/css/style.css index cacc456..cbf9a2d 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -376,9 +376,19 @@ section h2 { } /* Soundboard */ +.sounds-section { + grid-column: span 2; +} + +@media (max-width: 700px) { + .sounds-section { + grid-column: span 1; + } +} + .soundboard { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(6, 1fr); gap: 8px; } diff --git a/frontend/index.html b/frontend/index.html index 75a9e60..d9cc064 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -143,10 +143,10 @@
- + - - + +
diff --git a/podcast_stats.py b/podcast_stats.py index be80ae7..c5191da 100644 --- a/podcast_stats.py +++ b/podcast_stats.py @@ -15,6 +15,7 @@ Usage: import argparse import json +import os import re import subprocess import sys @@ -128,7 +129,7 @@ def gather_youtube(include_comments=False): try: proc = subprocess.run( - ["yt-dlp", "--dump-json", "--flat-playlist", + [os.path.join(os.path.dirname(os.path.abspath(__file__)), "venv", "bin", "yt-dlp"), "--dump-json", "--flat-playlist", f"https://www.youtube.com/playlist?list={YOUTUBE_PLAYLIST}"], capture_output=True, text=True, timeout=60 ) @@ -159,7 +160,7 @@ def gather_youtube(include_comments=False): for vid in video_ids: try: - cmd = ["yt-dlp", "--dump-json", "--no-download", f"https://www.youtube.com/watch?v={vid}"] + cmd = [os.path.join(os.path.dirname(os.path.abspath(__file__)), "venv", "bin", "yt-dlp"), "--dump-json", "--no-download", f"https://www.youtube.com/watch?v={vid}"] if include_comments: cmd.insert(2, "--write-comments") vr = subprocess.run(cmd, capture_output=True, text=True, timeout=90) @@ -203,7 +204,7 @@ def gather_youtube(include_comments=False): if videos: try: vr = subprocess.run( - ["yt-dlp", "--dump-json", "--no-download", "--playlist-items", "1", + [os.path.join(os.path.dirname(os.path.abspath(__file__)), "venv", "bin", "yt-dlp"), "--dump-json", "--no-download", "--playlist-items", "1", f"https://www.youtube.com/playlist?list={YOUTUBE_PLAYLIST}"], capture_output=True, text=True, timeout=30 ) diff --git a/postprod.py b/postprod.py index 7366e9d..76c7913 100644 --- a/postprod.py +++ b/postprod.py @@ -61,7 +61,7 @@ def compute_rms(audio: np.ndarray, window_samples: int) -> np.ndarray: def remove_gaps(stems: dict[str, np.ndarray], sr: int, - threshold_s: float = 2.0, max_gap_s: float = 8.0, + threshold_s: float = 2.0, max_gap_s: float = 15.0, crossfade_ms: float = 30, pad_s: float = 0.5) -> dict[str, np.ndarray]: window_ms = 50 window_samples = int(sr * window_ms / 1000) diff --git a/publish_episode.py b/publish_episode.py index fa72fc0..a1c746d 100755 --- a/publish_episode.py +++ b/publish_episode.py @@ -10,14 +10,15 @@ Usage: """ import argparse +import base64 import json import os import re +import shutil import subprocess import sys import tempfile -import base64 -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path import ssl @@ -61,6 +62,19 @@ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") WHISPER_MODEL = "base" # Options: tiny, base, small, medium, large +# Postiz (social media posting) +POSTIZ_URL = "https://social.lukeattheroost.com" +POSTIZ_JWT_SECRET = "9d499bab97b303506af6ae18b29a60e6b5a0b1049177f533232ad14dd9729814" +POSTIZ_USER_ID = "00c14319-9eac-42c3-a467-68d3c1634fe1" +POSTIZ_INTEGRATIONS = { + "facebook": {"id": "cmll9hwqj0001mt6xnas2f17w"}, + "instagram": {"id": "cmlljn8920001pk6qqzutqwik"}, + "discord": {"id": "cmllkprk90001uc6v6fwd5y9p", "channel": "1471386314447519754"}, + "bluesky": {"id": "cmlk29h780001p76qa7sstp5h"}, + "mastodon": {"id": "cmlk2r3mf0001le6vx9ey0k5a"}, + "nostr": {"id": "cmlll3y78000cuc6vh8dcpl2w"}, +} + # NAS Configuration for chapters upload # BunnyCDN Storage BUNNY_STORAGE_ZONE = "lukeattheroost" @@ -276,10 +290,23 @@ Respond with ONLY valid JSON, no markdown or explanation.""" return metadata -def create_episode(audio_path: str, metadata: dict, episode_number: int) -> dict: - """Create episode on Castopod using curl (handles large file uploads better).""" - print("[3/5] Creating episode on Castopod...") +CLOUDFLARE_UPLOAD_LIMIT = 100 * 1024 * 1024 # 100 MB + +def create_episode(audio_path: str, metadata: dict, episode_number: int, duration: int = 0) -> dict: + """Create episode on Castopod. Bypasses Cloudflare for large files.""" + file_size = os.path.getsize(audio_path) + + if file_size > CLOUDFLARE_UPLOAD_LIMIT: + print(f"[3/5] Creating episode on Castopod (direct, {file_size / 1024 / 1024:.0f} MB > 100 MB limit)...") + return _create_episode_direct(audio_path, metadata, episode_number, file_size, duration) + + print("[3/5] Creating episode on Castopod...") + return _create_episode_api(audio_path, metadata, episode_number) + + +def _create_episode_api(audio_path: str, metadata: dict, episode_number: int) -> dict: + """Create episode via Castopod REST API (through Cloudflare).""" credentials = base64.b64encode( f"{CASTOPOD_USERNAME}:{CASTOPOD_PASSWORD}".encode() ).decode() @@ -301,7 +328,7 @@ def create_episode(audio_path: str, metadata: dict, episode_number: int) -> dict "-F", f"episode_number={episode_number}", ] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=900) if result.returncode != 0: print(f"Error uploading: {result.stderr}") sys.exit(1) @@ -322,6 +349,107 @@ def create_episode(audio_path: str, metadata: dict, episode_number: int) -> dict return episode +def _create_episode_direct(audio_path: str, metadata: dict, episode_number: int, + file_size: int, duration: int) -> dict: + """Create episode by uploading directly to NAS and inserting into DB.""" + import time as _time + slug = re.sub(r'[^a-z0-9]+', '-', metadata["title"].lower()).strip('-') + timestamp = int(_time.time()) + rand_hex = os.urandom(10).hex() + filename = f"{timestamp}_{rand_hex}.mp3" + file_key = f"podcasts/{PODCAST_HANDLE}/{filename}" + nas_tmp = f"/share/CACHEDEV1_DATA/tmp/{filename}" + guid = f"{CASTOPOD_URL}/@{PODCAST_HANDLE}/episodes/{slug}" + desc_md = metadata["description"] + desc_html = f"

{desc_md}

" + duration_json = json.dumps({"playtime_seconds": duration, "avdataoffset": 85}) + + # SCP audio to NAS + print(" Uploading audio to NAS...") + scp_cmd = ["scp", "-P", str(NAS_SSH_PORT), audio_path, f"{NAS_USER}@{NAS_HOST}:{nas_tmp}"] + result = subprocess.run(scp_cmd, capture_output=True, text=True, timeout=600) + if result.returncode != 0: + print(f"Error: SCP failed: {result.stderr}") + sys.exit(1) + + # Docker cp into Castopod container + print(" Copying into Castopod container...") + media_path = f"/var/www/castopod/public/media/{file_key}" + cp_cmd = f'{DOCKER_PATH} cp {nas_tmp} {CASTOPOD_CONTAINER}:{media_path}' + success, output = run_ssh_command(cp_cmd, timeout=120) + if not success: + print(f"Error: docker cp failed: {output}") + sys.exit(1) + run_ssh_command(f'{DOCKER_PATH} exec {CASTOPOD_CONTAINER} chown www-data:www-data {media_path}') + run_ssh_command(f"rm -f {nas_tmp}") + + # Build SQL and transfer via base64 to avoid shell escaping issues + print(" Inserting media and episode records...") + + def _mysql_escape(s: str) -> str: + """Escape a string for MySQL single-quoted literals.""" + return s.replace("\\", "\\\\").replace("'", "\\'") + + title_esc = _mysql_escape(metadata["title"]) + desc_md_esc = _mysql_escape(desc_md) + desc_html_esc = _mysql_escape(desc_html) + duration_json_esc = _mysql_escape(duration_json) + + sql = ( + f"INSERT INTO cp_media (file_key, file_size, file_mimetype, file_metadata, type, " + f"uploaded_by, updated_by, uploaded_at, updated_at) VALUES " + f"('{file_key}', {file_size}, 'audio/mpeg', '{duration_json_esc}', 'audio', 1, 1, NOW(), NOW());\n" + f"SET @audio_id = LAST_INSERT_ID();\n" + f"INSERT INTO cp_episodes (podcast_id, guid, title, slug, audio_id, " + f"description_markdown, description_html, parental_advisory, number, type, " + f"is_blocked, is_published_on_hubs, is_premium, created_by, updated_by, " + f"published_at, created_at, updated_at) VALUES " + f"(1, '{guid}', '{title_esc}', '{slug}', @audio_id, " + f"'{desc_md_esc}', '{desc_html_esc}', 'explicit', {episode_number}, 'full', " + f"0, 0, 0, 1, 1, NOW(), NOW(), NOW());\n" + f"SELECT LAST_INSERT_ID();\n" + ) + + # Write SQL to local temp file, SCP to NAS, docker cp into MariaDB + local_sql_path = "/tmp/_castopod_insert.sql" + nas_sql_path = "/share/CACHEDEV1_DATA/tmp/_castopod_insert.sql" + with open(local_sql_path, "w") as f: + f.write(sql) + scp_sql = ["scp", "-P", str(NAS_SSH_PORT), local_sql_path, f"{NAS_USER}@{NAS_HOST}:{nas_sql_path}"] + result = subprocess.run(scp_sql, capture_output=True, text=True, timeout=30) + os.remove(local_sql_path) + if result.returncode != 0: + print(f"Error: failed to SCP SQL file: {result.stderr}") + sys.exit(1) + + # Copy SQL into MariaDB container and execute + run_ssh_command(f'{DOCKER_PATH} cp {nas_sql_path} {MARIADB_CONTAINER}:/tmp/_insert.sql') + exec_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} sh -c "mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -N < /tmp/_insert.sql"' + success, output = run_ssh_command(exec_cmd, timeout=30) + run_ssh_command(f'rm -f {nas_sql_path}') + run_ssh_command(f'{DOCKER_PATH} exec {MARIADB_CONTAINER} rm -f /tmp/_insert.sql') + + if not success: + print(f"Error: DB insert failed: {output}") + sys.exit(1) + + episode_id = int(output.strip().split('\n')[-1]) + # Get the audio media ID for CDN upload + audio_id_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -N -e "SELECT audio_id FROM cp_episodes WHERE id = {episode_id};"' + success, audio_id_str = run_ssh_command(audio_id_cmd) + audio_id = int(audio_id_str.strip()) if success else None + if audio_id: + print(f" Audio media ID: {audio_id}") + + # Clear cache + run_ssh_command(f'{DOCKER_PATH} exec {CASTOPOD_CONTAINER} php spark cache:clear') + + print(f" Created episode ID: {episode_id}") + print(f" Slug: {slug}") + + return {"id": episode_id, "slug": slug} + + def publish_episode(episode_id: int) -> dict: """Publish the episode.""" print("[4/5] Publishing episode...") @@ -451,7 +579,7 @@ def upload_to_bunny(local_path: str, remote_path: str, content_type: str = None) resp = requests.put(url, data=f, headers={ "AccessKey": BUNNY_STORAGE_KEY, "Content-Type": content_type, - }) + }, timeout=600) if resp.status_code == 201: return True print(f" Warning: BunnyCDN upload failed ({resp.status_code}): {resp.text[:200]}") @@ -461,7 +589,7 @@ def upload_to_bunny(local_path: str, remote_path: str, content_type: str = None) def download_from_castopod(file_key: str, local_path: str) -> bool: """Download a file from Castopod's container storage to local filesystem.""" remote_filename = Path(file_key).name - remote_tmp = f"/tmp/castopod_{remote_filename}" + remote_tmp = f"/share/CACHEDEV1_DATA/tmp/castopod_{remote_filename}" cp_cmd = f'{DOCKER_PATH} cp {CASTOPOD_CONTAINER}:/var/www/castopod/public/media/{file_key} {remote_tmp}' success, _ = run_ssh_command(cp_cmd, timeout=120) if not success: @@ -545,6 +673,174 @@ def add_episode_to_sitemap(slug: str): +def generate_social_image(episode_number: int, description: str, output_path: str) -> str: + """Generate a social media image with cover art, episode number, and description.""" + from PIL import Image, ImageDraw, ImageFont + import textwrap + + COVER_ART = Path(__file__).parent / "website" / "images" / "cover.png" + SIZE = 1080 + + img = Image.open(COVER_ART).convert("RGBA") + img = img.resize((SIZE, SIZE), Image.LANCZOS) + + # Dark gradient overlay on the bottom ~45% + gradient = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) + draw_grad = ImageDraw.Draw(gradient) + gradient_start = int(SIZE * 0.50) + for y in range(gradient_start, SIZE): + progress = (y - gradient_start) / (SIZE - gradient_start) + alpha = int(210 * progress) + draw_grad.line([(0, y), (SIZE, y)], fill=(0, 0, 0, alpha)) + + img = Image.alpha_composite(img, gradient) + draw = ImageDraw.Draw(img) + + # Fonts + try: + font_episode = ImageFont.truetype("/Library/Fonts/Montserrat-ExtraBold.ttf", 64) + font_desc = ImageFont.truetype("/Library/Fonts/Montserrat-Medium.ttf", 36) + font_url = ImageFont.truetype("/Library/Fonts/Montserrat-SemiBold.ttf", 28) + except OSError: + font_episode = ImageFont.truetype("/Library/Fonts/Arial Unicode.ttf", 64) + font_desc = ImageFont.truetype("/Library/Fonts/Arial Unicode.ttf", 36) + font_url = ImageFont.truetype("/Library/Fonts/Arial Unicode.ttf", 28) + + margin = 60 + max_width = SIZE - margin * 2 + + # Episode number + ep_text = f"EPISODE {episode_number}" + draw.text((margin, SIZE - 300), ep_text, font=font_episode, fill=(255, 200, 80)) + + # Description — word-wrap to fit + wrapped = textwrap.fill(description, width=45) + lines = wrapped.split("\n")[:4] # max 4 lines + if len(wrapped.split("\n")) > 4: + lines[-1] = lines[-1][:lines[-1].rfind(" ")] + "..." + desc_text = "\n".join(lines) + draw.text((margin, SIZE - 220), desc_text, font=font_desc, fill=(255, 255, 255, 230), + spacing=8) + + # Website URL — bottom right + url_text = "lukeattheroost.com" + bbox = draw.textbbox((0, 0), url_text, font=font_url) + url_width = bbox[2] - bbox[0] + draw.text((SIZE - margin - url_width, SIZE - 50), url_text, font=font_url, + fill=(255, 200, 80, 200)) + + img = img.convert("RGB") + img.save(output_path, "JPEG", quality=92) + print(f" Social image saved: {output_path}") + return output_path + + +def _get_postiz_token(): + """Generate a JWT token for Postiz API authentication.""" + import jwt + return jwt.encode( + {"id": POSTIZ_USER_ID, "email": "luke@macneilmediagroup.com", + "providerName": "LOCAL", "activated": True, "isSuperAdmin": False}, + POSTIZ_JWT_SECRET, algorithm="HS256" + ) + + +def upload_image_to_postiz(image_path: str) -> dict | None: + """Upload an image to Postiz and return the media object.""" + token = _get_postiz_token() + try: + with open(image_path, "rb") as f: + resp = requests.post( + f"{POSTIZ_URL}/api/media/upload-simple", + headers={"auth": token}, + files={"file": ("social.jpg", f, "image/jpeg")}, + timeout=30, + ) + if resp.status_code in (200, 201): + media = resp.json() + print(f" Uploaded image to Postiz (id: {media.get('id', 'unknown')})") + return media + else: + print(f" Warning: Postiz image upload returned {resp.status_code}: {resp.text[:200]}") + except Exception as e: + print(f" Warning: Postiz image upload failed: {e}") + return None + + +def post_to_social(metadata: dict, episode_slug: str, image_path: str = None): + """Post episode announcement to all connected social channels via Postiz.""" + print("[5.5/5] Posting to social media...") + + token = _get_postiz_token() + + # Upload image if provided + image_ids = [] + if image_path: + media = upload_image_to_postiz(image_path) + if media and media.get("id"): + image_ids = [{"id": media["id"], "path": media.get("path", "")}] + + episode_url = f"https://lukeattheroost.com/episode.html?slug={episode_slug}" + base_content = f"{metadata['title']}\n\n{metadata['description']}\n\n{episode_url}" + + hashtags = "#podcast #LukeAtTheRoost #talkradio #callinshow #newepisode" + hashtag_platforms = {"instagram", "facebook", "bluesky", "mastodon", "nostr"} + + # Platform-specific content length limits + PLATFORM_MAX_LENGTH = {"bluesky": 300} + + # Post to each platform individually so one failure doesn't block others + posted = 0 + for platform, intg_config in POSTIZ_INTEGRATIONS.items(): + content = base_content + if platform in hashtag_platforms: + content += f"\n\n{hashtags}" + + # Truncate for platforms with short limits + max_len = PLATFORM_MAX_LENGTH.get(platform) + if max_len and len(content) > max_len: + # Keep title + URL, truncate description + short = f"{metadata['title']}\n\n{episode_url}" + if platform in hashtag_platforms: + short += f"\n\n{hashtags}" + content = short[:max_len] + + settings = {"post_type": "post"} + if "channel" in intg_config: + settings["channel"] = intg_config["channel"] + + post = { + "integration": {"id": intg_config["id"]}, + "value": [{"content": content, "image": image_ids}], + "settings": settings, + } + + payload = { + "type": "now", + "shortLink": False, + "date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"), + "tags": [], + "posts": [post], + } + + try: + resp = requests.post( + f"{POSTIZ_URL}/api/posts", + headers={"auth": token, "Content-Type": "application/json"}, + json=payload, + timeout=60, + ) + if resp.status_code in (200, 201): + posted += 1 + print(f" Posted to {platform}") + else: + print(f" Warning: {platform} failed ({resp.status_code}): {resp.text[:150]}") + except Exception as e: + print(f" Warning: {platform} failed: {e}") + + print(f" Posted to {posted}/{len(POSTIZ_INTEGRATIONS)} channels") + + def get_next_episode_number() -> int: """Get the next episode number from Castopod.""" headers = get_auth_header() @@ -648,30 +944,37 @@ def main(): return # Step 3: Create episode - episode = create_episode(str(audio_path), metadata, episode_number) + direct_upload = os.path.getsize(str(audio_path)) > CLOUDFLARE_UPLOAD_LIMIT + episode = create_episode(str(audio_path), metadata, episode_number, duration=transcript["duration"]) # Step 3.5: Upload to BunnyCDN print("[3.5/5] Uploading to BunnyCDN...") uploaded_keys = set() - # Audio: download Castopod's copy (ensures byte-exact match with RSS metadata) + # Audio: query file_key from DB, then upload to CDN ep_id = episode["id"] audio_media_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -N -e "SELECT m.file_key FROM cp_media m JOIN cp_episodes e ON e.audio_id = m.id WHERE e.id = {ep_id};"' success, audio_file_key = run_ssh_command(audio_media_cmd) if success and audio_file_key: audio_file_key = audio_file_key.strip() - with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp: - tmp_audio = tmp.name - try: - print(f" Downloading from Castopod: {audio_file_key}") - if download_from_castopod(audio_file_key, tmp_audio): - print(f" Uploading audio to BunnyCDN") - upload_to_bunny(tmp_audio, f"media/{audio_file_key}", "audio/mpeg") - else: - print(f" Castopod download failed, uploading original file") - upload_to_bunny(str(audio_path), f"media/{audio_file_key}", "audio/mpeg") - finally: - Path(tmp_audio).unlink(missing_ok=True) + if direct_upload: + # Direct upload: we have the original file locally, upload straight to CDN + print(f" Uploading audio to BunnyCDN") + upload_to_bunny(str(audio_path), f"media/{audio_file_key}", "audio/mpeg") + else: + # API upload: download Castopod's copy (ensures byte-exact match with RSS metadata) + with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp: + tmp_audio = tmp.name + try: + print(f" Downloading from Castopod: {audio_file_key}") + if download_from_castopod(audio_file_key, tmp_audio): + print(f" Uploading audio to BunnyCDN") + upload_to_bunny(tmp_audio, f"media/{audio_file_key}", "audio/mpeg") + else: + print(f" Castopod download failed, uploading original file") + upload_to_bunny(str(audio_path), f"media/{audio_file_key}", "audio/mpeg") + finally: + Path(tmp_audio).unlink(missing_ok=True) uploaded_keys.add(audio_file_key) else: print(f" Error: Could not determine audio file_key from Castopod DB") @@ -688,7 +991,6 @@ def main(): upload_to_bunny(str(transcript_path), f"transcripts/{episode['slug']}.txt", "text/plain") # Copy transcript to website dir for Cloudflare Pages - import shutil website_transcript_dir = Path(__file__).parent / "website" / "transcripts" website_transcript_dir.mkdir(exist_ok=True) website_transcript_path = website_transcript_dir / f"{episode['slug']}.txt" @@ -698,8 +1000,16 @@ def main(): # Add to sitemap add_episode_to_sitemap(episode["slug"]) - # Step 4: Publish - episode = publish_episode(episode["id"]) + # Step 4: Publish via API (triggers RSS rebuild, federation, etc.) + try: + published = publish_episode(episode["id"]) + if "slug" in published: + episode = published + except SystemExit: + if direct_upload: + print(" Warning: Publish API failed, but episode is in DB with published_at set") + else: + raise # Step 4.5: Upload chapters via SSH chapters_uploaded = upload_chapters_to_castopod( @@ -712,8 +1022,26 @@ def main(): print(" Syncing episode media to CDN...") sync_episode_media_to_bunny(episode["id"], uploaded_keys) - # Step 5: Summary - print("\n[5/5] Done!") + # Step 5: Deploy website (transcript + sitemap must be live before social links go out) + print("[5/5] Deploying website...") + project_dir = Path(__file__).parent + deploy_result = subprocess.run( + ["npx", "wrangler", "pages", "deploy", "website/", + "--project-name=lukeattheroost", "--branch=main", "--commit-dirty=true"], + capture_output=True, text=True, cwd=project_dir, timeout=120 + ) + if deploy_result.returncode == 0: + print(" Website deployed") + else: + print(f" Warning: Website deploy failed: {deploy_result.stderr[:200]}") + + # Step 5.5: Generate social image and post + social_image_path = str(audio_path.with_suffix(".social.jpg")) + generate_social_image(episode_number, metadata["description"], social_image_path) + post_to_social(metadata, episode["slug"], social_image_path) + + # Step 6: Summary + print("\n[6/6] Done!") print("=" * 50) print(f"Episode URL: {CASTOPOD_URL}/@{PODCAST_HANDLE}/episodes/{episode['slug']}") print(f"RSS Feed: {CASTOPOD_URL}/@{PODCAST_HANDLE}/feed.xml") diff --git a/website/css/style.css b/website/css/style.css index 4bfab4d..fb179f3 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -181,22 +181,22 @@ a:hover { color: var(--text); } -/* Subscribe buttons */ +/* Subscribe buttons — primary listen platforms */ .subscribe-row { display: flex; flex-wrap: wrap; justify-content: center; - gap: 0.75rem; + gap: 0.6rem; margin-top: 1.5rem; } .subscribe-btn { display: inline-flex; align-items: center; - gap: 0.5rem; - padding: 0.6rem 1.25rem; + gap: 0.45rem; + padding: 0.55rem 1.1rem; border-radius: 50px; - font-size: 0.9rem; + font-size: 0.85rem; font-weight: 600; color: #fff; transition: opacity 0.2s, transform 0.2s; @@ -209,17 +209,47 @@ a:hover { } .subscribe-btn svg { - width: 18px; - height: 18px; + width: 16px; + height: 16px; flex-shrink: 0; } -.btn-hiw { background: var(--accent); } .btn-spotify { background: #1DB954; } .btn-youtube { background: #FF0000; } .btn-apple { background: #A033FF; } -.btn-discord { background: #5865F2; } -.btn-rss { background: #f26522; } + +/* Secondary links — How It Works, Discord, RSS */ +.secondary-links { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1.25rem; + 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; +} + +.secondary-link:hover { + background: var(--accent); + color: #fff; +} + +.secondary-link svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} /* Episodes */ .episodes-section { @@ -1166,6 +1196,10 @@ a:hover { justify-content: flex-start; } + .secondary-links { + justify-content: flex-start; + } + .episodes-section { padding: 2rem 2rem 3rem; } diff --git a/website/episode.html b/website/episode.html index 30e11f7..d6d9be3 100644 --- a/website/episode.html +++ b/website/episode.html @@ -21,7 +21,10 @@ + + + @@ -89,6 +92,10 @@ How It Works Stats Discord + Facebook + X + Bluesky + Mastodon Spotify YouTube RSS @@ -102,7 +109,7 @@ -

© 2026 Luke at the Roost

+

© 2026 Luke at the Roost · Privacy Policy

diff --git a/website/favicon-192.png b/website/favicon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..06b08ace5b0c9aa94b01a72312ece018cc24346b GIT binary patch literal 23600 zcmV*EKx@B=P)jV90l2!NyrisGV$GAWs& zC|Qe5S(e5c$&Q$bn6bk%_DpzI$sUJ25o3oWhsWWO$0J*^R@&Ck*3cp;QWQ;6B0+*A zKoB4apd08Ny;pVDTQcuG^Wol`nXkIut5-`e0C6H-zt{DayqS5H%=pI#eqwEr4 zC$JUR9zWj#X5;JYz%ahP0$fA6pu$BF&ZF|Oh(G2W$BoFb)0cav0C!8z?o~>!3BWpN z_wLz!?NZu`AeP{WFmh?BX@=cHx{-|1gw@}fq8D8vrccaX35BeD zV>JGwO2yf#Bkw;a@F(izvrvBerOU&om#yP9VOU153BU@pcQ3QH?7SV79|C>^xO+Xn zBg;Ul})yVwm;X$Q-zk2eV6XjzF|6#jpeeB7rSFbEv*SEYtye0sV z?%uwAuC#+60X_ioZYG#B(~vZtS7QA1Y^wKl^gS8Ai%%d5yx5UbAeMSS}30Ks_Hb%C9|qsrT&#?~Y^sO4~OW--w214ga?i|DL|5H{Ki{|KL6N^sK=Ia?;odRI1mW@ctN|0X&xl z{Wo6iKRI#v`csYb_?8faZzTbc1M}_g1N%2YzmH8m_vVk8_)lW~<&19yZ2Z5q@wI?8 z@v|~&4nfrP03JMoi_Gui->d(}ogoPaD%Gx#gh3m>36&ry=*K|+(My*GAB*SosuzM+ zy#PE6tt&e_KdCBz6L|Dy9AmlWHzvN0%%7Ouh+?C*&n#pTURwj!C|257k$NXs=B*|n z@E*KZ90R-ur?o&hYUZzYipn$+H2!VWmuMU#>>OXQqQCeZ7yG~U!G?MCYNS`Q0GNaG z-5*!w?<3rIGY`~scvoir!1x(K<7Xx?e`X;w8vbuOVFy6SOfZ8Cio{=66z`?h_9)JW z*G~I51?MF^Yb^xM2L^XE1Yx;}w9IcH3JLHcD*xcr<)wcClsEGl-Y$AI3V_)^*ZD~i z`FY^}n|-jA(4HpqTN{~QBI8@g1v0DTk@0g2nFPi+#$!dW!pPl`$ed0a%6I=3X8-)mUlsHp0b6e6ftTC;6}B(Ip$&F@gZXo%WdifJ ztkAZemK8d=@Qy<{?>^F^W!H8)p1auR-+!}UX()lQlPMI1p;W~=6JC3zjJrbVpl}ji z`>-RF#N8tL8BHPZ(dbzQ2`!=g-P4zQ|BzR3V%{rO01nN~zQKwA=K^oN)yH1R?oYw2 zEFwC&K$bzP!Tjy4L(2&5+|x3`zOC@nkF?mcrM>R8EcTTTK6{;u*L}484W$=KjXQ!< zhCb{H!!ig$;i2&TAOxNuAY~E)sr>~2y}=(?elfm;$X`8uVd;r=pTun>d%G8~R(cF_ znV$6`Gqcz$ zKf^1`Nq$A`0Q+`y4jUu?8hGdJTEmK_Ps8c0m_I(+R%z!QI!0(`K^We!uftC~oMFx8 zUH(&793MIE$c%9Rys~eLV>UN*Y>RW(2OPg@cq zpbSDVj6zUGXSr-dn^OP{Rt*m?is)ZEb+P}iHudCg65a78(BZ>$X3We#1Ac)`NLJej zji5fs^fAfqhdepG(e!EA$oyGg{(W2FXW!hVlTF9`rB|-@hYXw}w;8K#7+c#i@iRt* z`*&vCyIuL}1x3}PYEaEkv8ZOS8ji=}AoXP0s!gCj0BfC^kGQ7Hg0>!g#nmxLf zLkn-jOczDn1XwQ@9;FHHoF7*|S>1ZMb^~%}nC0pCVw)PksXAN}McFV~{;o^0N zi<&+}id~g77lL9EifuGpfHesL>^DY!YTHcq#l>EEY{QGb3AFJmc@yZseCKCT`7khl zyVtVZ?uU?;5X_%C(wm$5(Xs*V%&pS0!lA7>@4S1m^`;&^_2Q7nPYo%&#)z%ry*8K; zo;p`jx{dDw0{oeKeGJU`DnH5Ec9oi4Wu)qj-(3UkQk9SFpPTtFHoWMYKzG^>AP44W zegXJJ?trz8#rr4N{p}3eSxzVSv@=7;3V-2YSrzX5%u5B&oY&}d3K~_d#_}*D1|z{B z>9*R;ZGtJZqm%KCOK`R48ATd=u$T+5xhI zJG;LI`t!GYAuu`EWux}bh|ykwJd0@#rc29)Kv&zs(OoU>-rkvbtgoCa`Re%rr52RF zpwuCS53ijc;9S@Rl2DvkoDOPV91OWO98CPogF7WgtrcryG&}BaAst&sHU7rHtG|9= ze&$!X1C(2$J3;^+hStIP?r*E|)3Y=7R9`i)P|y4{!4r zD^;OR+Tp6Iv}6Wt8fvRQb6{up_wIxBhrc(GZo2^72kpyq-481K`0ZZHa!T4iRdl6b zS8{q=S&H@&O`k69$ow4(N9S7X+d5wRtA{^&0Sc9h;a!#CnFJzac&ch;lBhzz7|+$} z;q-#X8YrByFc|;Y`Pp_=upIVwJPfVNOWhB02Qq&X@kzD3Ha@EK zX4_!?3|d*6b{?3&Z5&5;wzzNCck8k z8+WNK1XpQtoxrh~4Idh9#Zp(IE$94%2q`zd-K6CVFDt+I?%~4BhwfwPho6JV&dN=q z+hqdT%k$k|2Y&E29e*q}HQzX=x6=G3IK6G#j*Ji8%<=vAb@_7-bS7%b=Pw$r4Oe$& zAKud%Vg6}op+Bh4MZiw5TZYRVgyo;COduIm5Mf7*X3`3e>isdlS(ASM(tP)~xJ}CV zb_jqR+}Zt?zz1%_vB%O4sLu*cZ)Q`XTbss^Tk3q)00-tfKc}Mq*gSJbyq9a z^R|tIROhJqfAZ0sy|dFHd#A2h41y&Tzh}`*$&+&(V+&SM_ zBAXydXRq1OmWuK3E7|Z>gD zKf6`O8%Hal8JUf~2-uqj;aqsGyUp<^!aD4K`A_k@u5s%QO+Y;uwGt9PI z7%`}~SSc|UjJA1k0s4hwORJ=lO}8VovW%_ml5OpLtSMDG<-^bQ=#|3IX(){GO6grF zuhh|9K}0I$Ai_vX$Hes2HLV^JkfY{yo1Ry8IIODt@JuWF;H}6az3HC!;lp%>3-WJ) zT{rt!<7h=>qoIW=o0~D+9J1*2=FqXV)BCy|Exu#_>Lm)7djmdy2Cnz(lEd7<>*m_r zyTh}+)7(9BF82mJce%sYFAVSqc=+Ze;rT1V-dWEZ_Q3Ah)i>`gt;vFU?5br@TD#*hS?b(VNy_;to+-kXZd(O+(ONLIWtjz1B^5WH!#erw< zRx=UeT#Y{UVxP0ue9-=-F!WIR0i~1JFDMFZx>gOd#+pW#y9g54#*WaOc`*7SP$dWB zgVg&N=65gl%imk~2~I&bRRBU6BeylVt;zgJ^FssaY1p#K`K;=Bv%vhFyutigNOJqW zBOP8hKl$>#eBJYzmkPYbu#$mo?VNYrBXsiBHFzj^x13xo>6cP>Zd1e6q43h>f<3d2 zZoc-7e4(#=^!X+FP8c{CdI?=4TuDK_8>EY8SjPBLr-3J>(TtlUpDG2lyuXa;JdcBUkCDwH|zJOp%LacHSe>(?S5R8hZZ3$HLI^ zhc77Srpyj)(i7(joTmPuArDFE2a}7Y-CrljC1sJS-`cs-%38E+c(k(?t*k|!wL;gs zculR1eNk5mj+rscA4ygI&qKT0H?76x&DsI7EwioPhv{c&-3+R1d} zn-1+Q#a$cr==UTKcfMz1anr*-)Ljfv4m2_%m&8KV?Un^_RJdIb8ly2r1wjw3r;Lb zgcJ>m0hDh)ptPq%ahB6eR`T?DD7}we-~{IdrNl|e=|woa!%pljR{~!+Rq&r)9MUVH zC=COb8f6VA6iO#m^%?1!nN<6n`kJ=Mvz$&2f9vgC-g6{p{}#*sEtWU!vplfV^7+#x zsur-Qx?uKb5`ty5#;c$H!E4>jE%wSkUf0E&K$~s?9h#ec1L)u0?B884jW9no(o<

Ui5(hmPse&6I8~%w$40cXabM-K@=?*^Ebb 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 Hu0mjffi&8} literal 0 HcmV?d00001 diff --git a/website/favicon-48.png b/website/favicon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..a750aa5d8cc8d6fc663a3198c04064adc65580ab GIT binary patch literal 3379 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 XtIR_{@EiO%-$G4ap=Qzl{S*2>C2{-= literal 0 HcmV?d00001 diff --git a/website/how-it-works.html b/website/how-it-works.html index e65df4a..6ce9b4d 100644 --- a/website/how-it-works.html +++ b/website/how-it-works.html @@ -19,7 +19,10 @@ + + + @@ -501,6 +504,10 @@ Home Stats Discord + Facebook + X + Bluesky + Mastodon Spotify YouTube RSS @@ -514,7 +521,7 @@

-

© 2026 Luke at the Roost

+

© 2026 Luke at the Roost · Privacy Policy

diff --git a/website/index.html b/website/index.html index 210f21f..a993d96 100644 --- a/website/index.html +++ b/website/index.html @@ -21,7 +21,10 @@ + + + @@ -91,27 +94,29 @@ (208-439-5853) + -

© 2026 Luke at the Roost

+

© 2026 Luke at the Roost · Privacy Policy

diff --git a/website/privacy.html b/website/privacy.html new file mode 100644 index 0000000..14791cc --- /dev/null +++ b/website/privacy.html @@ -0,0 +1,113 @@ + + + + + + Privacy Policy — Luke at the Roost + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

Who We Are

+

Luke at the Roost is a podcast and website operated by MacNeil Media Group. Our website is lukeattheroost.com.

+ +

Information We Collect

+

Website Visitors

+

We use Cloudflare Web Analytics, which collects anonymous, aggregated usage data (page views, referrers, country). It does not use cookies, does not track individual users, and does not collect personal information.

+ +

Podcast Listeners

+

When you download or stream an episode, standard server logs may record your IP address and user agent. We use this data only for aggregate download statistics. We do not sell or share this data with third parties.

+ +

Phone Callers

+

If you call in to the show at 208-439-LUKE, your voice may be recorded and included in a published episode. By calling in, you consent to being recorded and broadcast. We do not collect or store your phone number beyond what is necessary for call routing.

+ +

Social Media

+

We maintain a presence on platforms including Facebook, YouTube, Spotify, and Discord. When you interact with us on these platforms, their respective privacy policies apply. We may use third-party tools to schedule and manage social media posts.

+ +

Cookies

+

Our website does not set any first-party cookies. Third-party services (such as embedded podcast players) may set their own cookies according to their policies.

+ +

Third-Party Services

+

We use the following third-party services:

+
    +
  • Cloudflare — CDN, DNS, and analytics
  • +
  • BunnyCDN — Audio file delivery
  • +
  • Spotify, Apple Podcasts, YouTube — Podcast distribution
  • +
  • Discord — Community chat
  • +
  • Facebook — Social media page
  • +
+

Each service has its own privacy policy governing how they handle your data.

+ +

Data Retention

+

Aggregate analytics data is retained indefinitely. Server logs are retained for up to 90 days. Published episodes and transcripts are retained indefinitely as part of the public podcast archive.

+ +

Children's Privacy

+

Our content is rated explicit and is not directed at children under 13. We do not knowingly collect personal information from children.

+ +

Your Rights

+

If you have questions about your data or want to request removal of your voice from a published episode, contact us at luke@macneilmediagroup.com.

+ +

Changes

+

We may update this policy from time to time. Changes will be posted on this page with an updated date.

+ +

Contact

+

MacNeil Media Group
+ Email: luke@macneilmediagroup.com

+ +
+
+ + + + + + diff --git a/website/sitemap.xml b/website/sitemap.xml index 203b075..44bab6a 100644 --- a/website/sitemap.xml +++ b/website/sitemap.xml @@ -2,25 +2,25 @@ https://lukeattheroost.com - 2026-02-12 + 2026-02-15 weekly 1.0 https://lukeattheroost.com/how-it-works - 2026-02-11 + 2026-02-15 monthly 0.8 https://lukeattheroost.com/stats - 2026-02-12 + 2026-02-15 daily 0.6 https://lukeattheroost.com/privacy - 2026-02-12 + 2026-02-15 yearly 0.3 @@ -90,4 +90,10 @@ never 0.7 + + https://lukeattheroost.com/episode.html?slug=episode-12-love-lies-and-loyalty + 2026-02-14 + never + 0.7 + diff --git a/website/stats.html b/website/stats.html index 786be51..f7d129c 100644 --- a/website/stats.html +++ b/website/stats.html @@ -19,7 +19,10 @@ + + + @@ -58,6 +61,10 @@ Home How It Works Discord + Facebook + X + Bluesky + Mastodon Spotify YouTube RSS @@ -71,7 +78,7 @@ -

© 2026 Luke at the Roost

+

© 2026 Luke at the Roost · Privacy Policy