diff --git a/deploy_searxng.sh b/deploy_searxng.sh new file mode 100755 index 0000000..ceb62aa --- /dev/null +++ b/deploy_searxng.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Deploy SearXNG to the QNAP NAS (mmgnas) as Devon's always-on web-search backend. +# Public image — no build. Ships a settings.yml that enables the JSON API and +# disables the bot limiter so the backend can query format=json programmatically. +set -e + +NAS_PORT="8001" +NAS_USER="luke" +NAS_HOST="${NAS_HOST:-mmgnas}" # override with NAS_HOST=mmgnas-10g for wired +DOCKER="/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker" +DEPLOY_DIR="/share/CACHEDEV1_DATA/docker/searxng" +CONTAINER="searxng" +HOST_PORT="8888" +IMAGE="searxng/searxng:latest" + +SSH="ssh -p $NAS_PORT $NAS_USER@$NAS_HOST" + +echo "==> Ensuring deploy dir $DEPLOY_DIR" +$SSH "mkdir -p $DEPLOY_DIR" + +# Generate settings.yml only if absent (preserve secret_key across redeploys) +if ! $SSH "test -f $DEPLOY_DIR/settings.yml"; then + echo "==> Writing settings.yml (first deploy)" + SECRET=$(openssl rand -hex 32) + TMP=$(mktemp) + cat > "$TMP" < settings.yml already present — leaving it (and its secret_key) intact" +fi + +echo "==> Pulling $IMAGE" +$SSH "$DOCKER pull $IMAGE" + +echo "==> (Re)starting container" +$SSH "$DOCKER rm -f $CONTAINER 2>/dev/null || true" +$SSH "$DOCKER run -d --name $CONTAINER --restart unless-stopped \ + -p $HOST_PORT:8080 \ + -v $DEPLOY_DIR:/etc/searxng \ + -e SEARXNG_BASE_URL=http://$NAS_HOST:$HOST_PORT/ \ + $IMAGE" + +echo "==> Verifying" +sleep 6 +$SSH "$DOCKER ps --filter name=$CONTAINER --format '{{.Status}}'" +$SSH "$DOCKER logs $CONTAINER 2>&1 | tail -15" diff --git a/download_music.py b/download_music.py new file mode 100644 index 0000000..837476e --- /dev/null +++ b/download_music.py @@ -0,0 +1,192 @@ +"""Download vocal-free background music from Jamendo (CC-licensed). + +Targets late-night talk-radio vibe + hip-hop. Skips tracks shorter than 60s, +dedupes against existing files in music/, and appends CREDITS.txt entries. + +Usage: + python download_music.py # 100 tracks across all buckets + python download_music.py --count 30 # smaller batch + python download_music.py --dry-run # show what would be downloaded +""" + +import argparse +import os +import re +import sys +import time +from pathlib import Path +from urllib.request import urlopen, Request +from urllib.parse import urlencode + +from dotenv import load_dotenv + +load_dotenv() + +CLIENT_ID = os.getenv("JAMENDO_CLIENT_ID") +if not CLIENT_ID: + print("ERROR: JAMENDO_CLIENT_ID not set in .env", file=sys.stderr) + sys.exit(1) + +MUSIC_DIR = Path(__file__).parent / "music" +CREDITS_FILE = MUSIC_DIR / "CREDITS.txt" + +# (tag_query, genre_label, target_count) — hip-hop weighted heaviest per user pref +BUCKETS = [ + ("hiphop+instrumental", "Hip-Hop", 40), + ("jazz", "Jazz", 20), + ("lofi", "Lo-Fi", 15), + ("funk", "Funk", 15), + ("soul", "Soul", 10), +] + +# Filenames already on disk — skip duplicates by (artist, title) signature +def _existing_signatures() -> set[str]: + sigs = set() + for f in MUSIC_DIR.glob("*.mp3"): + # "Artist - Title [Genre].mp3" or "Artist - Title.mp3" + stem = f.stem + stem = re.sub(r"\s*\[[^\]]+\]\s*$", "", stem) + sigs.add(stem.lower().strip()) + for f in MUSIC_DIR.glob("*.wav"): + sigs.add(f.stem.lower().strip()) + return sigs + + +def _sanitize(s: str) -> str: + s = s.replace("/", "-").replace("\\", "-") + s = re.sub(r'[<>:"|?*]', "", s) + return s.strip() + + +def _fetch_jamendo_page(tag_query: str, offset: int, limit: int = 50) -> list[dict]: + params = { + "client_id": CLIENT_ID, + "format": "json", + "limit": limit, + "offset": offset, + "vocalinstrumental": "instrumental", + "fuzzytags": tag_query, + "audioformat": "mp32", + "include": "musicinfo+licenses", + "audiodlallowed": "true", + "ccnd": "true", # allow non-derivative (we won't modify) + "order": "popularity_total", + } + url = "https://api.jamendo.com/v3.0/tracks/?" + urlencode(params) + with urlopen(Request(url, headers={"User-Agent": "ai-podcast-music-fetcher/1.0"}), timeout=30) as r: + import json + data = json.load(r) + if data.get("headers", {}).get("status") != "success": + print(f" API error: {data.get('headers', {}).get('error_message')}") + return [] + return data.get("results", []) + + +def _download(url: str, dest: Path) -> bool: + try: + req = Request(url, headers={"User-Agent": "ai-podcast-music-fetcher/1.0"}) + with urlopen(req, timeout=120) as r, open(dest, "wb") as out: + while True: + chunk = r.read(64 * 1024) + if not chunk: + break + out.write(chunk) + return True + except Exception as e: + print(f" download failed: {e}") + if dest.exists(): + dest.unlink() + return False + + +def fetch_bucket(tag_query: str, genre_label: str, target: int, existing: set[str], dry_run: bool) -> list[tuple[Path, dict]]: + """Returns list of (path, track_info) successfully downloaded.""" + print(f"\n=== {genre_label} (target {target}) ===") + downloaded: list[tuple[Path, dict]] = [] + offset = 0 + seen_ids = set() + while len(downloaded) < target and offset < 500: # cap pagination + page = _fetch_jamendo_page(tag_query, offset) + if not page: + break + offset += len(page) + for track in page: + if len(downloaded) >= target: + break + tid = track.get("id") + if tid in seen_ids: + continue + seen_ids.add(tid) + + if track.get("duration", 0) < 60: + continue + if not track.get("audiodownload_allowed"): + continue + + artist = _sanitize(track.get("artist_name", "Unknown")) + name = _sanitize(track.get("name", "Untitled")) + sig = f"{artist} - {name}".lower().strip() + if sig in existing: + continue + + filename = f"{artist} - {name} [{genre_label}].mp3" + dest = MUSIC_DIR / filename + + audio_url = track.get("audiodownload") or track.get("audio") + if not audio_url: + continue + + if dry_run: + print(f" [DRY] {filename} ({track.get('duration')}s)") + downloaded.append((dest, track)) + existing.add(sig) + continue + + print(f" ↓ {filename} ({track.get('duration')}s)") + if _download(audio_url, dest): + downloaded.append((dest, track)) + existing.add(sig) + time.sleep(0.5) # be polite + if len(page) < 50: + break + return downloaded + + +def append_credits(entries: list[tuple[Path, dict]]): + if not entries: + return + with open(CREDITS_FILE, "a") as f: + f.write(f"\n# Added {time.strftime('%Y-%m-%d')} — vocal-free batch via Jamendo API\n") + for dest, track in entries: + license_url = track.get("license_ccurl", "") + share_url = track.get("shareurl", "") + artist = track.get("artist_name", "") + name = track.get("name", "") + f.write(f"{dest.name} | {artist} - {name} | {license_url} | {share_url}\n") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--count", type=int, default=100, help="Total tracks (default 100)") + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + MUSIC_DIR.mkdir(exist_ok=True) + existing = _existing_signatures() + print(f"Existing tracks: {len(existing)}") + + # Scale bucket targets proportionally to --count + scale = args.count / sum(b[2] for b in BUCKETS) + all_new: list[tuple[Path, dict]] = [] + for tag, label, weight in BUCKETS: + target = max(1, round(weight * scale)) + all_new.extend(fetch_bucket(tag, label, target, existing, args.dry_run)) + + print(f"\n=== Done. {len(all_new)} new tracks {'planned' if args.dry_run else 'downloaded'}. ===") + if not args.dry_run: + append_credits(all_new) + print(f"CREDITS.txt updated.") + + +if __name__ == "__main__": + main() diff --git a/generate_milestone_images.py b/generate_milestone_images.py new file mode 100644 index 0000000..4b3f8cf --- /dev/null +++ b/generate_milestone_images.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Generate 1000 downloads milestone images using Nano Banana 2 (Gemini Flash Image).""" + +import os +from pathlib import Path +from google import genai +from google.genai import types + +# Load .env manually +env_path = Path(__file__).parent / ".env" +if env_path.exists(): + for line in env_path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + +client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY")) +MODEL = "gemini-3.1-flash-image-preview" +OUTPUT_DIR = Path("social_posts/1000_milestone") +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + +def generate_image(prompt: str, filename: str, aspect_ratio: str = "1:1"): + print(f"Generating {filename}...") + response = client.models.generate_content( + model=MODEL, + contents=[prompt], + config=types.GenerateContentConfig( + response_modalities=["TEXT", "IMAGE"], + image_config=types.ImageConfig( + aspect_ratio=aspect_ratio, + image_size="2K", + ), + ), + ) + for part in response.parts: + if part.inline_data is not None: + image = part.as_image() + path = OUTPUT_DIR / filename + image.save(str(path)) + print(f" Saved: {path}") + return + print(f" WARNING: No image generated for {filename}") + + +STYLE_BASE = ( + "Professional podcast promotional graphic. Dark navy/black background with subtle " + "warm amber and gold accent lighting, evoking a late-night radio studio atmosphere. " + "Clean modern typography. No photorealistic people. Subtle microphone and radio wave " + "design elements. Polished, minimal, high contrast." +) + +images = [ + { + "filename": "main_milestone_square.png", + "aspect_ratio": "1:1", + "prompt": ( + f"{STYLE_BASE} " + "Large bold glowing text '1,000' as the hero element in the center, with " + "'DOWNLOADS' directly below it in a thinner font. Below that in smaller text: " + "'27 episodes · 200+ callers · 1 month'. " + "At the top: 'LUKE AT THE ROOST' in elegant lettering. " + "Subtle golden microphone icon above the title. " + "At the bottom: 'lukeattheroost.com' in small clean text. " + "The overall feel is celebratory but classy, like a late-night milestone announcement." + ), + }, + { + "filename": "main_milestone_twitter.png", + "aspect_ratio": "16:9", + "prompt": ( + f"{STYLE_BASE} " + "Wide banner format. Left side has 'LUKE AT THE ROOST' title with a subtle " + "microphone graphic. Right side has large bold glowing '1,000 DOWNLOADS' text " + "with '27 episodes · 200+ callers · 1 month' below. " + "Warm amber glow connecting the two sides. " + "Bottom right corner: 'lukeattheroost.com'. " + "Designed as a Twitter/X post image." + ), + }, + { + "filename": "carousel_1_downloads.png", + "aspect_ratio": "1:1", + "prompt": ( + f"{STYLE_BASE} " + "Instagram carousel slide 1. Giant bold text '1,000' taking up most of the frame, " + "with 'DOWNLOADS' below. Subtle radio wave ripples emanating from the numbers. " + "'LUKE AT THE ROOST' at the top in small elegant text. " + "Small '1/5' page indicator dots at the bottom." + ), + }, + { + "filename": "carousel_2_episodes.png", + "aspect_ratio": "1:1", + "prompt": ( + f"{STYLE_BASE} " + "Instagram carousel slide 2. Large bold text '27' in the center with " + "'EPISODES' below. A subtle audio waveform timeline graphic running horizontally " + "behind the number. 'LUKE AT THE ROOST' at the top in small elegant text. " + "Small '2/5' page indicator dots at the bottom." + ), + }, + { + "filename": "carousel_3_callers.png", + "aspect_ratio": "1:1", + "prompt": ( + f"{STYLE_BASE} " + "Instagram carousel slide 3. Large bold text '200+' in the center with " + "'CALLERS' below. Subtle vintage telephone handset icon above the number. " + "'LUKE AT THE ROOST' at the top in small elegant text. " + "Small '3/5' page indicator dots at the bottom." + ), + }, + { + "filename": "carousel_4_regulars.png", + "aspect_ratio": "1:1", + "prompt": ( + f"{STYLE_BASE} " + "Instagram carousel slide 4. Large bold text '13' in the center with " + "'RETURNING REGULARS' below. Subtle connected dots/nodes graphic suggesting " + "a network of recurring characters. " + "'LUKE AT THE ROOST' at the top in small elegant text. " + "Small '4/5' page indicator dots at the bottom." + ), + }, + { + "filename": "carousel_5_thankyou.png", + "aspect_ratio": "1:1", + "prompt": ( + f"{STYLE_BASE} " + "Instagram carousel slide 5. Warm, heartfelt tone. Large elegant text " + "'THANK YOU' in the center with a soft golden glow. Below: " + "'lukeattheroost.com' and '208-439-LUKE' in clean small text. " + "'LUKE AT THE ROOST' at the top in small elegant text. " + "Small '5/5' page indicator dots at the bottom. " + "Slightly warmer color temperature than the other slides to feel like a closing moment." + ), + }, +] + +if __name__ == "__main__": + for img in images: + try: + generate_image(img["prompt"], img["filename"], img["aspect_ratio"]) + except Exception as e: + print(f" ERROR generating {img['filename']}: {e}") + print(f"\nDone! Images saved to {OUTPUT_DIR}/") diff --git a/make_x_launch_assets.py b/make_x_launch_assets.py new file mode 100644 index 0000000..c09d54a --- /dev/null +++ b/make_x_launch_assets.py @@ -0,0 +1,644 @@ +#!/usr/bin/env python3 +"""Generate all visual assets for the X/Twitter launch campaign. + +Creates: + 1. X header image (1500x500) + 2. 7 branded quote cards (1080x1080 + 1200x675) + 3. "Welcome to the show" intro graphic + 4. "Leave us a review" graphic + +Usage: + python make_x_launch_assets.py +""" + +import os +from PIL import Image, ImageDraw, ImageFont + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +COVER = os.path.join(SCRIPT_DIR, "website/images/cover.png") +OUT_DIR = os.path.join(SCRIPT_DIR, "social_posts/x_launch") + +# Brand colors +BG = (18, 13, 7) +ACCENT = (232, 121, 29) +WHITE = (255, 255, 255) +MUTED = (175, 165, 150) +LIGHTER = (220, 215, 205) +DARK_PANEL = (30, 22, 12) +ACCENT_DIM = (140, 75, 18) + +# macOS system fonts +FONT_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf" +FONT_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf" +FONT_REG = "/System/Library/Fonts/Supplemental/Arial.ttf" +FONT_ITALIC = "/System/Library/Fonts/Supplemental/Arial Italic.ttf" + + +def font(path, size): + return ImageFont.truetype(path, size) + + +def text_size(draw, text, f): + bb = draw.textbbox((0, 0), text, font=f) + return bb[2] - bb[0], bb[3] - bb[1] + + +def paste_cover(img, x, y, size, radius=16): + cover = Image.open(COVER).resize((size, size), Image.LANCZOS) + mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, size, size], radius=radius, fill=255) + img.paste(cover, (x, y), mask) + + +def wrap_text_centered(draw, text, center_x, y, max_w, f, fill, line_gap=10): + """Word-wrap text, centered on each line. Returns y below last line.""" + words = text.split() + lines = [] + cur = "" + for word in words: + test = f"{cur} {word}".strip() + tw, _ = text_size(draw, test, f) + if tw > max_w and cur: + lines.append(cur) + cur = word + else: + cur = test + if cur: + lines.append(cur) + + for line in lines: + tw, th = text_size(draw, line, f) + draw.text((center_x - tw // 2, y), line, font=f, fill=fill) + y += th + line_gap + return y + + +def wrap_text_left(draw, text, x, y, max_w, f, fill, line_gap=10): + """Word-wrap text, left-aligned. Returns y below last line.""" + words = text.split() + lines = [] + cur = "" + for word in words: + test = f"{cur} {word}".strip() + tw, _ = text_size(draw, test, f) + if tw > max_w and cur: + lines.append(cur) + cur = word + else: + cur = test + if cur: + lines.append(cur) + + for line in lines: + _, th = text_size(draw, line, f) + draw.text((x, y), line, font=f, fill=fill) + y += th + line_gap + return y + + +def measure_wrap_height(draw, text, max_w, f, line_gap=10): + """Measure how tall wrapped text would be without drawing.""" + words = text.split() + lines = [] + cur = "" + for word in words: + test = f"{cur} {word}".strip() + tw, _ = text_size(draw, test, f) + if tw > max_w and cur: + lines.append(cur) + cur = word + else: + cur = test + if cur: + lines.append(cur) + total = 0 + for line in lines: + _, th = text_size(draw, line, f) + total += th + line_gap + return total + + +def accent_bars(draw, w, h, thickness): + draw.rectangle([0, 0, w, thickness], fill=ACCENT) + draw.rectangle([0, h - thickness, w, h], fill=ACCENT) + + +def center_text(draw, text, y, canvas_w, f, fill): + tw, th = text_size(draw, text, f) + draw.text(((canvas_w - tw) // 2, y), text, font=f, fill=fill) + return y + th + + +# ── 1. X HEADER (1500x500) ────────────────────────────────────────── + +def make_header(): + W, H = 1500, 500 + img = Image.new("RGB", (W, H), BG) + draw = ImageDraw.Draw(img) + + # Amber accent bars + accent_bars(draw, W, H, 6) + + # Subtle amber glow on left + for i in range(200): + alpha = int(18 * (1 - i / 200)) + draw.rectangle([0, 0, i, H], fill=(18 + alpha, 13 + alpha // 2, 7)) + + # Cover art — right side + cover_size = 340 + cover_x = W - cover_size - 60 + cover_y = (H - cover_size) // 2 + paste_cover(img, cover_x, cover_y, cover_size, 20) + + # Left side content + mx = 80 + cy = 100 + + # Show name + draw.text((mx, cy), "LUKE AT THE ROOST", font=font(FONT_BLACK, 72), fill=WHITE) + cy += 90 + + # Tagline + draw.text((mx, cy), "Late-Night Call-In Radio", font=font(FONT_REG, 36), fill=ACCENT) + cy += 50 + draw.text((mx, cy), "Powered by AI", font=font(FONT_BOLD, 30), fill=MUTED) + cy += 55 + + # Divider line + draw.rectangle([mx, cy, mx + 400, cy + 3], fill=ACCENT) + cy += 20 + + # Info line + draw.text((mx, cy), "New episodes daily | lukeattheroost.com", + font=font(FONT_REG, 24), fill=MUTED) + + img.save(os.path.join(OUT_DIR, "x_header_1500x500.png"), quality=95) + print("Created: x_header_1500x500.png") + + +# ── 2. QUOTE CARDS ────────────────────────────────────────────────── + +QUOTES = [ + { + "quote": "Everybody is a fake. We're all fakes. Nobody knows what's going on and none of us deserve a goddamn thing. We're lucky to be here at all.", + "caller": "Luke", + "episode": "Ep. 2", + "slug": "were_all_fakes", + }, + { + "quote": "When my hands are busy, my head is quiet.", + "caller": "Frank", + "episode": "Ep. 12", + "context": "on building bird houses after losing his wife", + "slug": "hands_busy_head_quiet", + }, + { + "quote": "I've been using stoicism backwards\u2014as an excuse not to try instead of finding peace after I've actually done something. That's not stoicism, that's just being a coward with a fancy excuse.", + "caller": "Caller", + "episode": "Ep. 19", + "slug": "stoicism_backwards", + }, + { + "quote": "You're right. I am a computer-generated AI caller. And you're sitting there alone talking to me at midnight like it's a real conversation.", + "caller": "AI Caller", + "episode": "Ep. 24", + "slug": "ai_caller_reveal", + }, + { + "quote": "I burned my second marriage to the ground doing exactly what you're doing. My ex-wife didn't leave because I wasn't making money\u2014she left because I wasn't there.", + "caller": "Mikey", + "episode": "Ep. 22", + "slug": "burned_my_marriage", + }, + { + "quote": "My mom said all she wants is to see her kids eat cake together. That's it. Just cake.", + "caller": "Caller", + "episode": "Ep. 30", + "context": "on a dying mother's final wish", + "slug": "just_eat_cake", + }, + { + "quote": "I told my sister I had prostate cancer to get out of her fourth wedding. Now there's been a GoFundMe, a pancake breakfast fundraiser, and my cousin shaved his head for me.", + "caller": "Caller", + "episode": "Ep. 32", + "slug": "faked_cancer_wedding", + }, +] + + +def make_quote_square(q, idx): + W = 1080 + img = Image.new("RGB", (W, W), BG) + draw = ImageDraw.Draw(img) + accent_bars(draw, W, W, 6) + + mx = 70 + max_w = W - mx * 2 + + # Header bar + draw.text((mx, 40), "LUKE AT THE ROOST", font=font(FONT_BOLD, 22), fill=ACCENT) + paste_cover(img, W - 120, 30, 70, 10) + + # Font size — aggressive scaling for short quotes + quote_len = len(q["quote"]) + if quote_len < 55: + qfont_size = 72 + elif quote_len < 100: + qfont_size = 56 + elif quote_len < 150: + qfont_size = 44 + else: + qfont_size = 38 + qfont = font(FONT_BOLD, qfont_size) + line_gap = 16 + + # Measure total content block height to center it + open_quote_h = 80 + quote_gap = 20 + quote_h = measure_wrap_height(draw, q["quote"], max_w, qfont, line_gap=line_gap) + close_quote_h = 78 # glyph + attribution inline + attr_gap = 0 + divider_h = 0 + attr_h = 0 + context_h = 35 if "context" in q else 0 + + total_h = open_quote_h + quote_gap + quote_h + close_quote_h + attr_gap + divider_h + attr_h + context_h + + # Center between header (y=90) and footer (y=W-70) + avail_top = 100 + avail_bottom = W - 80 + avail_h = avail_bottom - avail_top + y = avail_top + (avail_h - total_h) // 2 + + # Opening quote mark + draw.text((mx - 15, y), "\u201c", font=font(FONT_BLACK, 100), fill=ACCENT_DIM) + y += open_quote_h + quote_gap + + # Quote text + y = wrap_text_left(draw, q["quote"], mx, y, max_w, qfont, WHITE, line_gap=line_gap) + + # Closing quote mark + attribution + close_y = y + 8 + draw.text((mx - 15, close_y), "\u201d", font=font(FONT_BLACK, 80), fill=ACCENT_DIM) + attr = f"\u2014 {q['caller']}, {q['episode']}" + draw.text((mx + 70, close_y + 25), attr, font=font(FONT_BOLD, 28), fill=ACCENT) + y = close_y + 70 + + if "context" in q: + draw.text((mx, y), q["context"], font=font(FONT_ITALIC, 24), fill=MUTED) + + # Footer + footer_y = W - 70 + center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube", + footer_y, W, font(FONT_REG, 20), MUTED) + + fname = f"quote_{idx + 1}_{q['slug']}_square.png" + img.save(os.path.join(OUT_DIR, fname), quality=95) + print(f"Created: {fname}") + + +def make_quote_landscape(q, idx): + W, H = 1200, 675 + img = Image.new("RGB", (W, H), BG) + draw = ImageDraw.Draw(img) + accent_bars(draw, W, H, 5) + + mx = 55 + max_w = W - mx * 2 + + # Header + draw.text((mx, 28), "LUKE AT THE ROOST", font=font(FONT_BOLD, 18), fill=ACCENT) + paste_cover(img, W - 90, 22, 52, 8) + + # Font size — aggressive scaling for short quotes + quote_len = len(q["quote"]) + if quote_len < 55: + qfont_size = 52 + elif quote_len < 100: + qfont_size = 42 + elif quote_len < 150: + qfont_size = 34 + else: + qfont_size = 28 + qfont = font(FONT_BOLD, qfont_size) + + # Measure total content block height + open_quote_h = 55 + quote_gap = 15 + quote_h = measure_wrap_height(draw, q["quote"], max_w, qfont, line_gap=10) + close_quote_h = 60 # glyph + attribution inline + attr_gap = 0 + divider_h = 0 + attr_h = 0 + context_h = 30 if "context" in q else 0 + + total_h = open_quote_h + quote_gap + quote_h + close_quote_h + attr_gap + divider_h + attr_h + context_h + + # Center between header (y=65) and footer (y=H-50) + avail_top = 65 + avail_bottom = H - 55 + avail_h = avail_bottom - avail_top + y = avail_top + (avail_h - total_h) // 2 + + # Opening quote mark + draw.text((mx - 10, y), "\u201c", font=font(FONT_BLACK, 72), fill=ACCENT_DIM) + y += open_quote_h + quote_gap + + # Quote text + y = wrap_text_left(draw, q["quote"], mx, y, max_w, qfont, WHITE, line_gap=10) + + # Closing quote + attribution + close_y = y + 5 + draw.text((mx - 10, close_y), "\u201d", font=font(FONT_BLACK, 60), fill=ACCENT_DIM) + attr = f"\u2014 {q['caller']}, {q['episode']}" + draw.text((mx + 55, close_y + 18), attr, font=font(FONT_BOLD, 22), fill=ACCENT) + y = close_y + 55 + + if "context" in q: + draw.text((mx, y), q["context"], font=font(FONT_ITALIC, 19), fill=MUTED) + + # Footer + footer_y = H - 50 + center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube", + footer_y, W, font(FONT_REG, 17), MUTED) + + fname = f"quote_{idx + 1}_{q['slug']}_twitter.png" + img.save(os.path.join(OUT_DIR, fname), quality=95) + print(f"Created: {fname}") + + +# ── 3. WELCOME TO THE SHOW ────────────────────────────────────────── + +def make_welcome_square(): + W = 1080 + img = Image.new("RGB", (W, W), BG) + draw = ImageDraw.Draw(img) + accent_bars(draw, W, W, 8) + + cx = W // 2 + + # Cover art — centered, large + cover_size = 280 + paste_cover(img, cx - cover_size // 2, 60, cover_size, 24) + + y = 60 + cover_size + 40 + + # Title + y = wrap_text_centered(draw, "WELCOME TO THE SHOW", cx, y, W - 120, + font(FONT_BLACK, 64), WHITE, line_gap=12) + y += 20 + + # Divider + draw.rectangle([cx - 60, y, cx + 60, y + 4], fill=ACCENT) + y += 30 + + # Description + desc = "Late-night call-in radio powered entirely by AI. Real conversations with AI callers about life, love, and everything in between." + y = wrap_text_centered(draw, desc, cx, y, W - 140, + font(FONT_REG, 30), LIGHTER, line_gap=12) + y += 30 + + # Features + features = [ + "New episodes daily", + "AI-generated callers with real personalities", + "Unscripted. Unfiltered. Unpredictable.", + ] + for feat in features: + line = f"\u2022 {feat}" + tw, th = text_size(draw, line, font(FONT_REG, 26)) + draw.text((cx - tw // 2, y), line, font=font(FONT_REG, 26), fill=MUTED) + y += th + 14 + + y += 20 + + # CTA + cta = "FOLLOW @LUKEATTHEROOST" + cta_font = font(FONT_BOLD, 32) + tw, th = text_size(draw, cta, cta_font) + px, py = 28, 16 + box_w = tw + px * 2 + box_h = th + py * 2 + box_x = cx - box_w // 2 + draw.rounded_rectangle([box_x, y, box_x + box_w, y + box_h], + radius=10, fill=ACCENT) + draw.text((box_x + px, y + py), cta, font=cta_font, fill=BG) + + y += box_h + 24 + + # Footer + center_text(draw, "lukeattheroost.com", y, W, font(FONT_REG, 22), MUTED) + + img.save(os.path.join(OUT_DIR, "welcome_to_the_show_square.png"), quality=95) + print("Created: welcome_to_the_show_square.png") + + +def make_welcome_landscape(): + W, H = 1200, 675 + img = Image.new("RGB", (W, H), BG) + draw = ImageDraw.Draw(img) + accent_bars(draw, W, H, 6) + + # Cover art — left side + cover_size = 260 + cover_x, cover_y = 50, (H - cover_size) // 2 + paste_cover(img, cover_x, cover_y, cover_size, 20) + + # Right side content + rx = cover_x + cover_size + 50 + max_w = W - rx - 50 + y = 60 + + # Title + y = wrap_text_left(draw, "WELCOME TO THE SHOW", rx, y, max_w, + font(FONT_BLACK, 48), WHITE, line_gap=10) + y += 16 + + # Divider + draw.rectangle([rx, y, rx + 80, y + 3], fill=ACCENT) + y += 20 + + # Description + desc = "Late-night call-in radio powered entirely by AI. Real conversations with AI callers about life, love, and everything in between." + y = wrap_text_left(draw, desc, rx, y, max_w, + font(FONT_REG, 22), LIGHTER, line_gap=10) + y += 20 + + # Features + features = [ + "New episodes daily", + "AI callers with real personalities", + "Unscripted. Unfiltered. Unpredictable.", + ] + for feat in features: + line = f"\u2022 {feat}" + draw.text((rx, y), line, font=font(FONT_REG, 20), fill=MUTED) + y += 32 + + y += 10 + + # CTA + cta = "FOLLOW @LUKEATTHEROOST" + cta_font = font(FONT_BOLD, 24) + tw, th = text_size(draw, cta, cta_font) + px, py = 22, 12 + draw.rounded_rectangle([rx, y, rx + tw + px * 2, y + th + py * 2], + radius=8, fill=ACCENT) + draw.text((rx + px, y + py), cta, font=cta_font, fill=BG) + + # Footer + center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube", + H - 45, W, font(FONT_REG, 17), MUTED) + + img.save(os.path.join(OUT_DIR, "welcome_to_the_show_twitter.png"), quality=95) + print("Created: welcome_to_the_show_twitter.png") + + +# ── 4. LEAVE US A REVIEW ──────────────────────────────────────────── + +def make_review_square(): + W = 1080 + img = Image.new("RGB", (W, W), BG) + draw = ImageDraw.Draw(img) + accent_bars(draw, W, W, 8) + + cx = W // 2 + + # Cover art + cover_size = 200 + paste_cover(img, cx - cover_size // 2, 55, cover_size, 20) + y = 55 + cover_size + 35 + + # Title + y = wrap_text_centered(draw, "LOVE THE SHOW?", cx, y, W - 120, + font(FONT_BLACK, 64), WHITE, line_gap=12) + y += 10 + y = wrap_text_centered(draw, "LEAVE US A REVIEW", cx, y, W - 120, + font(FONT_BLACK, 64), ACCENT, line_gap=12) + y += 30 + + # Divider + draw.rectangle([cx - 50, y, cx + 50, y + 3], fill=ACCENT) + y += 30 + + # Body text + body = "Reviews help new listeners find the show. If Luke at the Roost has made you laugh, think, or question your life choices\u2014take 30 seconds to leave a rating." + y = wrap_text_centered(draw, body, cx, y, W - 140, + font(FONT_REG, 28), LIGHTER, line_gap=12) + y += 35 + + # Stars + stars = "\u2605 \u2605 \u2605 \u2605 \u2605" + center_text(draw, stars, y, W, font(FONT_REG, 52), ACCENT) + y += 70 + + # Platforms + platforms = ["Apple Podcasts", "Spotify", "YouTube", "Podchaser"] + for plat in platforms: + tw, th = text_size(draw, plat, font(FONT_BOLD, 26)) + px, py = 30, 12 + box_w = tw + px * 2 + box_x = cx - box_w // 2 + draw.rounded_rectangle( + [box_x, y, box_x + box_w, y + th + py * 2], + radius=8, fill=DARK_PANEL, outline=ACCENT_DIM, width=2, + ) + draw.text((box_x + px, y + py), plat, font=font(FONT_BOLD, 26), fill=LIGHTER) + y += th + py * 2 + 12 + + # Footer + center_text(draw, "lukeattheroost.com", W - 65, W, font(FONT_REG, 20), MUTED) + + img.save(os.path.join(OUT_DIR, "leave_a_review_square.png"), quality=95) + print("Created: leave_a_review_square.png") + + +def make_review_landscape(): + W, H = 1200, 675 + img = Image.new("RGB", (W, H), BG) + draw = ImageDraw.Draw(img) + accent_bars(draw, W, H, 6) + + # Cover art — left + cover_size = 200 + cover_x, cover_y = 50, (H - cover_size) // 2 + paste_cover(img, cover_x, cover_y, cover_size, 16) + + # Right content + rx = cover_x + cover_size + 50 + max_w = W - rx - 50 + y = 50 + + # Title + y = wrap_text_left(draw, "LOVE THE SHOW?", rx, y, max_w, + font(FONT_BLACK, 48), WHITE, line_gap=8) + y += 6 + y = wrap_text_left(draw, "LEAVE US A REVIEW", rx, y, max_w, + font(FONT_BLACK, 48), ACCENT, line_gap=8) + y += 16 + + # Stars + stars = "\u2605 \u2605 \u2605 \u2605 \u2605" + draw.text((rx, y), stars, font=font(FONT_REG, 40), fill=ACCENT) + y += 55 + + # Body + body = "Reviews help new listeners find the show. Take 30 seconds to leave a rating\u2014it makes a huge difference." + y = wrap_text_left(draw, body, rx, y, max_w, + font(FONT_REG, 22), LIGHTER, line_gap=10) + y += 25 + + # Platform pills — inline + platforms = ["Apple Podcasts", "Spotify", "YouTube", "Podchaser"] + pill_x = rx + pill_font = font(FONT_BOLD, 19) + for plat in platforms: + tw, th = text_size(draw, plat, pill_font) + px, py = 16, 8 + pill_w = tw + px * 2 + if pill_x + pill_w > W - 50: + pill_x = rx + y += th + py * 2 + 10 + draw.rounded_rectangle( + [pill_x, y, pill_x + pill_w, y + th + py * 2], + radius=6, fill=DARK_PANEL, outline=ACCENT_DIM, width=2, + ) + draw.text((pill_x + px, y + py), plat, font=pill_font, fill=LIGHTER) + pill_x += pill_w + 10 + + # Footer + center_text(draw, "lukeattheroost.com", H - 40, W, font(FONT_REG, 17), MUTED) + + img.save(os.path.join(OUT_DIR, "leave_a_review_twitter.png"), quality=95) + print("Created: leave_a_review_twitter.png") + + +# ── MAIN ───────────────────────────────────────────────────────────── + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + + print("\n=== X Launch Campaign Assets ===\n") + + print("--- Header ---") + make_header() + + print("\n--- Quote Cards ---") + for i, q in enumerate(QUOTES): + make_quote_square(q, i) + make_quote_landscape(q, i) + + print("\n--- Welcome to the Show ---") + make_welcome_square() + make_welcome_landscape() + + print("\n--- Leave a Review ---") + make_review_square() + make_review_landscape() + + print(f"\nAll assets saved to: {OUT_DIR}/") + print(f"Total files: {len(os.listdir(OUT_DIR))}") + + +if __name__ == "__main__": + main() diff --git a/post_milestone.py b/post_milestone.py new file mode 100644 index 0000000..2933e1a --- /dev/null +++ b/post_milestone.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Post 1000 downloads milestone to all social platforms via Postiz.""" + +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +import requests + +# Load .env +env_path = Path(__file__).parent / ".env" +if env_path.exists(): + for line in env_path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + +POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY") +POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com") + +IMAGE_PATH = Path(__file__).parent / "social_posts" / "1000_milestone" / "main_1000_celebration.jpg" + + +def get_api_url(path: str) -> str: + return f"{POSTIZ_URL.rstrip('/')}/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_image(file_path: Path) -> dict: + headers = {"Authorization": POSTIZ_API_KEY} + mime = "image/jpeg" if file_path.suffix.lower() in (".jpg", ".jpeg") else "image/png" + with open(file_path, "rb") as f: + resp = requests.post( + get_api_url("/upload"), + headers=headers, + files={"file": (file_path.name, f, mime)}, + timeout=60, + ) + if resp.status_code not in (200, 201): + 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) -> dict: + date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z") + payload = { + "type": "now", + "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 failed: {resp.status_code} {resp.text[:300]}") + return {} + return resp.json() + + +# --- Post content per platform --- + +POSTS = { + "instagram": { + "content": """1,000 downloads in one month. 🎙️ + +27 episodes. 200+ callers. 111 unique characters. 13 returning regulars. + +Luke at the Roost is a late-night call-in show where AI-generated characters phone in with their problems — relationship drama, moral dilemmas, conspiracy theories, drunk confessions — and I give them real advice, live. + +Every caller has a unique voice, a backstory, and a reason for calling. Some of them keep calling back. + +Thank you to everyone who's tuned in. This thing was supposed to be a weird experiment. Now it's a weird experiment that 1,000 people have listened to. + +New episodes daily. Link in bio. + +#podcast #ai #artificialintelligence #sideproject #indieproject #podcastlife #latenightradio #callinshow #milestone #1000downloads""", + "settings": {"__type": "instagram", "post_type": "post", "collaborators": []}, + }, + "facebook": { + "content": """🎙️ MILESTONE: 1,000 Downloads + +One month ago I launched a weird experiment — a late-night call-in radio show where AI-generated characters phone in with their problems, and I give them real advice. + +27 episodes later, 200+ callers have phoned in. Some of them keep calling back. Listeners have favorites. People genuinely care about what happens to these characters. + +Thank you to every single person who gave this a listen. It started as a side project and it's become something I look forward to every day. + +Listen free: lukeattheroost.com +Call in for real: 208-439-LUKE""", + "settings": {"__type": "facebook"}, + }, + "threads": { + "content": """1,000 downloads in one month. 🎙️ + +27 episodes. 200+ callers. 111 unique characters. 13 returning regulars. + +Luke at the Roost is a late-night call-in show where AI-generated characters phone in with their problems and I give them real advice, live. + +Thank you to everyone who's tuned in. This weird experiment just hit a milestone. + +lukeattheroost.com + +#podcast #ai #sideproject #1000downloads""", + "settings": {"__type": "threads"}, + }, + "linkedin": { + "content": """1,000 downloads in 30 days — here's what I learned building an AI radio show + +A month ago I launched Luke at the Roost, a late-night call-in radio show where every caller is an AI-generated character. I'm the host. They phone in with problems. I give them advice. Every conversation is improvised. + +27 episodes and 200+ callers later, the show just hit 1,000 downloads. + +Some things that surprised me: + +People connect with AI characters. Listeners have favorites. They ask about regulars by name. When a caller's story evolves across episodes, people notice and care. The characters aren't real, but the emotional engagement is. + +Constraints drive creativity. Each caller gets a token budget based on their personality type. Emotional callers get more room to ramble. Gossip callers are quick and punchy. This artificial constraint mirrors how real people actually talk — and it makes every call feel distinct. + +The tech is the easy part. LLMs, voice synthesis, audio routing — that's engineering. The hard part is being a good host. Knowing when to push, when to listen, when to make a joke. AI handles the callers. The human skill is the conversation. + +The full technical breakdown: lukeattheroost.com/how-it-works +Listen: lukeattheroost.com + +Thank you to everyone who gave this weird experiment a chance.""", + "settings": {"__type": "linkedin"}, + }, + "mastodon": { + "content": """1,000 downloads. 27 episodes. 200+ AI callers given advice on everything from breakups to fish consciousness. + +Luke at the Roost hit a milestone today and I just want to say thank you to everyone who's been listening. + +This whole thing is self-hosted end-to-end — Castopod on a QNAP NAS, Cloudflare CDN, custom Python pipeline for recording, post-production, and publishing. No big platforms in the loop. + +If you haven't heard it: it's a late-night call-in show. AI characters phone in. I talk to them live. It's improvised, weird, and somehow heartfelt. + +https://lukeattheroost.com""", + "settings": {"__type": "mastodon"}, + }, + "tiktok": { + "content": """1,000 downloads in one month 🎙️ + +27 episodes. 200+ AI callers. 13 returning regulars. + +Luke at the Roost — a late-night call-in show where AI characters phone in with their problems and I give them real advice, live. + +Thank you to everyone listening. + +#podcast #ai #artificialintelligence #sideproject #latenightradio #callinshow #1000downloads""", + "settings": { + "__type": "tiktok", + "privacy_level": "PUBLIC_TO_EVERYONE", + "duet": False, + "stitch": False, + "comment": True, + "autoAddMusic": "no", + "brand_content_toggle": False, + "brand_organic_toggle": False, + "content_posting_method": "DIRECT_POST", + }, + }, + "nostr": { + "content": """1,000 downloads. 27 episodes. 200+ AI callers given advice. + +Luke at the Roost just hit a milestone. Thank you to everyone listening. + +It's a late-night call-in show where AI-generated characters phone in with their problems and I give them real advice, live. Every conversation is improvised. + +https://lukeattheroost.com""", + "settings": {"__type": "nostr"}, + }, +} + + +def main(): + dry_run = "--dry-run" in sys.argv + + if not POSTIZ_API_KEY: + print("Error: POSTIZ_API_KEY not set") + sys.exit(1) + + if not IMAGE_PATH.exists(): + print(f"Error: Image not found at {IMAGE_PATH}") + sys.exit(1) + + print("Fetching connected accounts from Postiz...") + integrations = fetch_integrations() + + available = {} + for platform in POSTS: + integ = find_integration(integrations, platform) + if integ: + available[platform] = integ + print(f" ✓ {platform}: {integ.get('name', 'connected')}") + else: + print(f" ✗ {platform}: not connected, skipping") + + if not available: + print("\nNo platforms available!") + sys.exit(1) + + print(f"\nWill post to {len(available)} platform(s) with image: {IMAGE_PATH.name}") + + if dry_run: + print("\n--- DRY RUN ---") + for platform in available: + print(f"\n[{platform.upper()}]") + print(POSTS[platform]["content"][:200] + "...") + print("\nDry run complete — nothing posted.") + return + + # Upload image once + print(f"\nUploading image...") + media = upload_image(IMAGE_PATH) + if not media: + print("Failed to upload image, aborting") + sys.exit(1) + print(f" Uploaded: {media.get('path', 'ok')}") + + # Post to each platform + results = {} + for platform, integ in available.items(): + post_data = POSTS[platform] + print(f"\nPosting to {platform}...") + result = create_post(integ["id"], post_data["content"], media, post_data["settings"]) + if result: + print(f" ✓ {platform}: Posted!") + results[platform] = True + else: + print(f" ✗ {platform}: Failed") + results[platform] = False + + # Summary + succeeded = [p for p, ok in results.items() if ok] + failed = [p for p, ok in results.items() if not ok] + print(f"\n{'='*40}") + print(f"Posted to {len(succeeded)}/{len(results)} platforms") + if succeeded: + print(f" ✓ {', '.join(succeeded)}") + if failed: + print(f" ✗ {', '.join(failed)}") + + +if __name__ == "__main__": + main() diff --git a/reaper/bleep_selection.lua b/reaper/bleep_selection.lua new file mode 100644 index 0000000..33bf794 --- /dev/null +++ b/reaper/bleep_selection.lua @@ -0,0 +1,159 @@ +-- Bleep Selection — censor a time range on the selected track(s) +-- +-- Replaces the audio inside the time selection with a 1kHz tone rather than +-- muting it. Muted regions read as silence to strip_silence_dialog.lua +-- (SILENCE_DB -30, thresholds 5-6s), so a muted bleep over a slowly-read phone +-- number can get stripped and shift everything after it out of sync. A tone at +-- TONE_GAIN_DB is nowhere near -30, so the silence pass leaves it alone. +-- +-- Usage: make a time selection over the digits, select the track, run. + +--------------------------------------------------------------------------- +-- SETTINGS +--------------------------------------------------------------------------- +local TONE_GAIN_DB = 0.0 -- adjust bleep level; file is generated at -15 dBFS +local FADE_MS = 4.0 -- fade in/out on the tone, prevents clicks +local TONE_FILE = "bleep_1khz.wav" -- 60s 1kHz sine @48k, sits next to this script + +--------------------------------------------------------------------------- + +local EPS = 1e-9 + +local function script_dir() + local src = debug.getinfo(1, "S").source + return src:match("@?(.*[/\\])") or "" +end + +local function items_on(track) + local t = {} + for i = 0, reaper.CountTrackMediaItems(track) - 1 do + t[#t + 1] = reaper.GetTrackMediaItem(track, i) + end + return t +end + +local function item_bounds(item) + local pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION") + return pos, pos + reaper.GetMediaItemInfo_Value(item, "D_LENGTH") +end + +-- Split every item crossing either edge so the range is cleanly separable +local function split_at_edges(track, sel_start, sel_end) + for _, item in ipairs(items_on(track)) do + local pos, fin = item_bounds(item) + if fin > sel_start + EPS and pos < sel_end - EPS then + local right = item + if pos < sel_start - EPS then + right = reaper.SplitMediaItem(item, sel_start) + end + if right then + local rpos, rfin = item_bounds(right) + if rfin > sel_end + EPS and rpos < sel_end - EPS then + reaper.SplitMediaItem(right, sel_end) + end + end + end + end +end + +local function delete_inside(track, sel_start, sel_end) + local removed = 0 + for _, item in ipairs(items_on(track)) do + local pos, fin = item_bounds(item) + if pos >= sel_start - EPS and fin <= sel_end + EPS then + reaper.DeleteTrackMediaItem(track, item) + removed = removed + 1 + end + end + return removed +end + +-- Build the item directly rather than via InsertMedia(): InsertMedia behaves +-- like the user-facing "insert media file" action — it obeys ripple editing +-- (shifting other tracks), can spawn a new track, and moves the edit cursor. +-- AddMediaItemToTrack touches nothing but this track. +local function insert_tone(track, sel_start, sel_len, tone_source) + local item = reaper.AddMediaItemToTrack(track) + if not item then return nil end + local take = reaper.AddTakeToMediaItem(item) + if not take then return nil end + reaper.SetMediaItemTake_Source(take, tone_source) + + reaper.SetMediaItemInfo_Value(item, "D_POSITION", sel_start) + reaper.SetMediaItemInfo_Value(item, "D_LENGTH", sel_len) + reaper.SetMediaItemInfo_Value(item, "B_LOOPSRC", 0) + reaper.SetMediaItemInfo_Value(item, "D_VOL", 10 ^ (TONE_GAIN_DB / 20)) + + local fade = math.min(FADE_MS / 1000, sel_len / 2) + reaper.SetMediaItemInfo_Value(item, "D_FADEINLEN", fade) + reaper.SetMediaItemInfo_Value(item, "D_FADEOUTLEN", fade) + return item +end + +--------------------------------------------------------------------------- + +local function main() + local sel_start, sel_end = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false) + local sel_len = sel_end - sel_start + if sel_len <= 0 then + reaper.ShowMessageBox("Make a time selection over the audio to bleep.", "Bleep Selection", 0) + return + end + + local n_tracks = reaper.CountSelectedTracks(0) + if n_tracks == 0 then + reaper.ShowMessageBox("Select the track to bleep.", "Bleep Selection", 0) + return + end + + local tone_path = script_dir() .. TONE_FILE + local f = io.open(tone_path, "rb") + if not f then + reaper.ShowMessageBox("Tone file not found:\n" .. tone_path, "Bleep Selection", 0) + return + end + f:close() + + local tone_source = reaper.PCM_Source_CreateFromFile(tone_path) + if not tone_source then + reaper.ShowMessageBox("Could not load tone file:\n" .. tone_path, "Bleep Selection", 0) + return + end + + local targets = {} + for i = 0, n_tracks - 1 do + targets[#targets + 1] = reaper.GetSelectedTrack(0, i) + end + + reaper.Undo_BeginBlock() + reaper.PreventUIRefresh(1) + + -- Ripple editing would shift unrelated items (and other tracks) when items + -- are removed. Force it off for the duration, restore the user's mode after. + local ripple_per_track = reaper.GetToggleCommandStateEx(0, 40310) == 1 + local ripple_all = reaper.GetToggleCommandStateEx(0, 40311) == 1 + if ripple_per_track or ripple_all then + reaper.Main_OnCommand(40309, 0) -- ripple editing off + end + + local bleeped = 0 + for _, track in ipairs(targets) do + split_at_edges(track, sel_start, sel_end) + delete_inside(track, sel_start, sel_end) + if insert_tone(track, sel_start, sel_len, tone_source) then + bleeped = bleeped + 1 + end + end + + if ripple_all then + reaper.Main_OnCommand(40311, 0) + elseif ripple_per_track then + reaper.Main_OnCommand(40310, 0) + end + + reaper.PreventUIRefresh(-1) + reaper.UpdateArrange() + reaper.Undo_EndBlock(string.format("Bleep %.2fs on %d track(s)", sel_len, bleeped), -1) +end + +main() diff --git a/schedule_x_launch.py b/schedule_x_launch.py new file mode 100644 index 0000000..dcae8f6 --- /dev/null +++ b/schedule_x_launch.py @@ -0,0 +1,538 @@ +#!/usr/bin/env python3 +"""Schedule the X/Twitter launch campaign posts via Postiz. + +Schedules 2 weeks of posts from the growth strategy to @lukeattheroost. +All times are ET, converted to UTC for the Postiz API. + +Usage: + python schedule_x_launch.py # schedule all posts + python schedule_x_launch.py --dry-run # preview without scheduling + python schedule_x_launch.py --week 1 # schedule week 1 only +""" + +import argparse +import json +import os +import sys +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import requests + +# Load .env +env_path = Path(__file__).parent / ".env" +if env_path.exists(): + for line in env_path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + +POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY") +POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com") + +SCRIPT_DIR = Path(__file__).parent +X_INTEGRATION_ID = "cmlk4hi880001k76wbqjo21s0" + +# ET = UTC-4 (EDT) in March 2026 +ET_OFFSET = timedelta(hours=-4) + + +def et_to_utc(year, month, day, hour, minute=0): + """Convert ET datetime to UTC ISO string for Postiz.""" + et = datetime(year, month, day, hour, minute, tzinfo=timezone(ET_OFFSET)) + utc = et.astimezone(timezone.utc) + return utc.strftime("%Y-%m-%dT%H:%M:%S.000Z") + + +def get_api_url(path): + return f"{POSTIZ_URL.rstrip('/')}/api/public/v1{path}" + + +def api_headers(): + return {"Authorization": POSTIZ_API_KEY, "Content-Type": "application/json"} + + +def upload_file(file_path): + headers = {"Authorization": POSTIZ_API_KEY} + suffix = file_path.suffix.lower() + if suffix == ".mp4": + mime = "video/mp4" + elif suffix in (".jpg", ".jpeg"): + mime = "image/jpeg" + else: + mime = "image/png" + + with open(file_path, "rb") as f: + resp = requests.post( + get_api_url("/upload"), + headers=headers, + files={"file": (file_path.name, f, mime)}, + 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 schedule_post(content, media, schedule_time, retries=3): + payload = { + "type": "schedule", + "date": schedule_time, + "shortLink": False, + "tags": [], + "posts": [ + { + "integration": {"id": X_INTEGRATION_ID}, + "value": [ + { + "content": content, + "image": [media] if media else [], + } + ], + "settings": {"__type": "x", "who_can_reply_post": "everyone"}, + } + ], + } + for attempt in range(retries): + resp = requests.post( + get_api_url("/posts"), + headers=api_headers(), + json=payload, + timeout=30, + ) + if resp.status_code in (200, 201): + return resp.json() + if resp.status_code == 429 and attempt < retries - 1: + wait = 15 * (attempt + 1) + print(f"(rate limited, waiting {wait}s)...", end=" ", flush=True) + time.sleep(wait) + continue + print(f" Schedule failed: {resp.status_code} {resp.text[:300]}") + return {} + return {} + + +# ── POST DEFINITIONS ───────────────────────────────────────────────── +# Day 1 = Monday March 17, 2026 + +WEEK_1 = [ + # Day 1 — Monday March 17 + { + "label": "W1-Mon-AM (pinned intro)", + "time": et_to_utc(2026, 3, 17, 10), + "content": """Every caller on my show is AI-generated. + +Every personality. Every voice. Every problem they call in about. + +But the conversations are real, the advice is real, and the chaos is very real. + +38 episodes. 200+ callers. A cult leader, a guy who opened paternity results live on air, and someone who faked cancer to skip a wedding. + +This is Luke at the Roost. + +📞 208-439-LUKE (real humans can call in too) +🔗 lukeattheroost.com""", + "media": "website/images/cover.png", + }, + { + "label": "W1-Mon-PM (chili clip)", + "time": et_to_utc(2026, 3, 17, 14), + "content": """A guy called in to talk about chili contest cheaters. + +Turns out he was really calling about his failing marriage. + +#lukeattheroost #podcast #callinshow""", + "media": "clips/episode-37/clip-1-chili-contest-cheaters-marriage-troubles.mp4", + }, + { + "label": "W1-Mon-EVE (intro thread)", + "time": et_to_utc(2026, 3, 17, 21), + "content": """People keep asking what Luke at the Roost is. + +Short version: +→ AI characters call into my show with real problems +→ I give them actual advice +→ Everything goes off the rails +→ New episode every day + +lukeattheroost.com""", + "media": None, + }, + # Day 2 — Tuesday March 18 + { + "label": "W1-Tue-AM (hospice clip)", + "time": et_to_utc(2026, 3, 18, 12), + "content": """A caller's mom is in hospice. The nurse asked her to think about final conversations. + +Her only wish? To see her kids eat cake together one last time. + +#lukeattheroost #podcast""", + "media": "clips/episode-30/clip-2-mom-s-dying-wish-just-eat-cake-together.mp4", + }, + { + "label": "W1-Tue-PM (engagement)", + "time": et_to_utc(2026, 3, 18, 19), + "content": """What's the wildest thing you've ever called into a radio show about? + +(Or wanted to but chickened out?)""", + "media": None, + }, + # Day 3 — Wednesday March 19 + { + "label": "W1-Wed-AM (cancer clip)", + "time": et_to_utc(2026, 3, 19, 11), + "content": """This caller faked having cancer to get out of going to a wedding. + +Then his friends staged a coffee enema intervention. + +I can't make this up. (Well, the AI did.) + +#lukeattheroost #podcast""", + "media": "clips/episode-32/clip-1-i-faked-cancer-to-skip-a-wedding.mp4", + }, + { + "label": "W1-Wed-PM (fakes clip)", + "time": et_to_utc(2026, 3, 19, 16), + "content": """"Everybody is a fake. We're all fakes. Nobody knows what's going on and none of us deserve a goddamn thing. We're lucky to be here at all." + +— A caller on Episode 2. Still the hardest truth anyone's dropped on my show. + +#lukeattheroost #podcast""", + "media": "clips/episode-2/clip-1-we-re-all-fakes-and-that-s-okay.mp4", + }, + # Day 4 — Thursday March 20 + { + "label": "W1-Thu-AM (BTS)", + "time": et_to_utc(2026, 3, 20, 12), + "content": """How my show works: + +→ AI generates a caller with a full backstory, personality, and voice +→ They call in live +→ I have zero idea what they're going to say +→ I give them real advice +→ Post-production runs automatically +→ Episode publishes + +38 episodes. Built the whole thing from scratch.""", + "media": None, + }, + { + "label": "W1-Thu-PM (stakeout clip)", + "time": et_to_utc(2026, 3, 20, 20), + "content": """He spent four hours staking out his best friend at Starbucks. + +What he found was worse than what he expected. + +#lukeattheroost #podcast""", + "media": "clips/episode-28/clip-3-four-hours-spying-on-his-best-friend.mp4", + }, + # Day 5 — Friday March 21 + { + "label": "W1-Fri-AM (review ask)", + "time": et_to_utc(2026, 3, 21, 11), + "content": """If you've listened to Luke at the Roost and liked it — a review on Apple Podcasts or Spotify goes further than you'd think. + +Not guilt-tripping. Just saying it helps a one-person show more than anything else. + +🔗 lukeattheroost.com""", + "media": "social_posts/x_launch/leave_a_review_twitter.png", + }, + { + "label": "W1-Fri-PM (wall clip)", + "time": et_to_utc(2026, 3, 21, 18), + "content": """She opened up a mystery wall in her house LIVE on the show. + +There were stacks of cash inside. + +#lukeattheroost #podcast""", + "media": "clips/episode-35/clip-3-woman-finds-cash-in-secret-wall.mp4", + }, + # Day 6 — Saturday March 22 + { + "label": "W1-Sat-AM (poll)", + "time": et_to_utc(2026, 3, 22, 12), + "content": """Which is wilder? + +A) Woman hid her daughter from her husband for 8 years +B) Cult leader existential crisis on air +C) Paternity results opened live on the show +D) Faked cancer to skip a wedding""", + "media": None, + }, + { + "label": "W1-Sat-PM (silence clip)", + "time": et_to_utc(2026, 3, 22, 20), + "content": """"I told my girlfriend my biggest fantasy and she went completely silent for 10 seconds." + +The silence in this clip is brutal. + +#lukeattheroost #podcast""", + "media": "clips/episode-30/clip-3-latex-fetish-confession-goes-silent.mp4", + }, + # Day 7 — Sunday March 23 + { + "label": "W1-Sun-PM (week recap)", + "time": et_to_utc(2026, 3, 23, 15), + "content": """One week on X. Dropped 38 episodes before making an account. + +If any of these clips made you laugh, cringe, or feel something — the full episodes are even wilder. + +📞 208-439-LUKE +🔗 lukeattheroost.com +🎧 Spotify · Apple · YouTube""", + "media": "website/images/cover.png", + }, +] + +WEEK_2 = [ + # Day 8 — Monday March 24 + { + "label": "W2-Mon-AM (second family clip)", + "time": et_to_utc(2026, 3, 24, 10), + "content": """A guy called in and found out his dad had a whole second family. + +Three kids in Tucson who grew up calling his dad "Dad." + +He found out via email from a stranger. + +#lukeattheroost #podcast""", + "media": "clips/episode-20/clip-2-dad-s-secret-second-family-revealed.mp4", + }, + { + "label": "W2-Mon-PM (engagement)", + "time": et_to_utc(2026, 3, 24, 19), + "content": """What would you do if you found out your dad had a whole second family? + +Asking because a caller found out via email from a woman in Tucson.""", + "media": None, + }, + # Day 9 — Tuesday March 25 + { + "label": "W2-Tue-AM (spanish clip)", + "time": et_to_utc(2026, 3, 25, 11), + "content": """A caller pretended to speak Spanish at his job for 8 years. + +Eight. Years. + +#lukeattheroost #podcast""", + "media": "clips/episode-14/clip-1-i-lied-about-speaking-spanish-for-8-years.mp4", + }, + { + "label": "W2-Tue-PM (quote)", + "time": et_to_utc(2026, 3, 25, 18), + "content": """"Middle management is plagiarism with a 401k." + +AI callers drop better one-liners than most standup specials. + +#lukeattheroost #podcast""", + "media": "social_posts/x_launch/quote_3_stoicism_backwards_twitter.png", + }, + # Day 10 — Wednesday March 26 + { + "label": "W2-Wed-AM (BTS)", + "time": et_to_utc(2026, 3, 26, 12), + "content": """People ask if I script the show. + +I don't even know who's calling until they're on the line. The AI generates the caller, picks a unique voice, gives them a backstory, and dials in. + +My job is just to be a good host. The chaos handles itself.""", + "media": None, + }, + { + "label": "W2-Wed-PM (roomba clip)", + "time": et_to_utc(2026, 3, 26, 20), + "content": """His neighbor's Roomba broke into his kitchen at 2:30 AM. + +This is the content you're here for. + +#lukeattheroost #podcast""", + "media": "clips/episode-26/clip-2-neighbor-s-roomba-breaks-into-kitchen-at-2-30-am.mp4", + }, + # Day 11 — Thursday March 27 + { + "label": "W2-Thu-AM (stalking clip)", + "time": et_to_utc(2026, 3, 27, 11), + "content": """She sat in a Dairy Queen parking lot for 20 minutes watching her ex's truck at Sonic across the street. + +We've all been there. (Right?) + +#lukeattheroost #podcast""", + "media": "clips/episode-22/clip-2-stalking-your-ex-at-sonic.mp4", + }, + { + "label": "W2-Thu-PM (engagement)", + "time": et_to_utc(2026, 3, 27, 19), + "content": """Be honest: what's a lie you've kept going for way too long? + +A caller on my show pretended to speak Spanish at work for 8 years. You can't beat that.""", + "media": None, + }, + # Day 12 — Friday March 28 + { + "label": "W2-Fri-AM (hidden room clip)", + "time": et_to_utc(2026, 3, 28, 10), + "content": """A man found an impossible hidden room in a junkyard. + +Inside? Beer that was still fresh after 12 years. + +Two clips. One mystery. + +#lukeattheroost #podcast""", + "media": "clips/episode-34/clip-1-man-finds-impossible-hidden-room-in-junkyard.mp4", + }, + { + "label": "W2-Fri-PM (fix quote)", + "time": et_to_utc(2026, 3, 28, 18), + "content": """"You can't fix somebody who doesn't want to be fixed." + +A caller said this about their partner and it hit like a truck. + +#lukeattheroost #podcast""", + "media": "clips/episode-30/clip-1-you-can-t-fix-someone-who-won-t-be-fixed.mp4", + }, + # Day 13 — Saturday March 29 + { + "label": "W2-Sat-AM (BTS callers)", + "time": et_to_utc(2026, 3, 29, 12), + "content": """Each AI caller gets generated with: + +• A name, age, job, and hometown +• A reason for calling +• A communication style + energy level +• An emotional state +• A "signature detail" that makes them unique +• A unique AI voice + +None of it is scripted. They just... call in and talk.""", + "media": None, + }, + { + "label": "W2-Sat-PM (check clip)", + "time": et_to_utc(2026, 3, 29, 20), + "content": """He deposited a $5,000 check instead of $500 three months ago. + +Spent it all. + +Now his company might find out. + +#lukeattheroost #podcast""", + "media": "clips/episode-25/clip-2-accidentally-kept-4-500-from-work.mp4", + }, + # Day 14 — Sunday March 30 + { + "label": "W2-Sun-PM (week 2 recap + review)", + "time": et_to_utc(2026, 3, 30, 15), + "content": """Two weeks in. Thank you to everyone who's followed, listened, or dropped a comment. + +This show started as a weird experiment — a guy giving life advice to AI-generated callers at 2 AM. + +38 episodes later it's still weird. But now people are listening. + +If you've been enjoying it, a rating on Apple or Spotify makes a real difference. 🙏 + +lukeattheroost.com""", + "media": None, + }, +] + + +def main(): + parser = argparse.ArgumentParser(description="Schedule X launch campaign via Postiz") + parser.add_argument("--dry-run", action="store_true", help="Preview without scheduling") + parser.add_argument("--week", type=int, choices=[1, 2], help="Schedule only week 1 or 2") + parser.add_argument("--skip", type=int, default=0, help="Skip first N posts (for retrying after partial run)") + parser.add_argument("--delay", type=int, default=10, help="Seconds between API calls (default 10)") + args = parser.parse_args() + + if not POSTIZ_API_KEY: + print("Error: POSTIZ_API_KEY not set in .env") + sys.exit(1) + + all_posts = [] + if args.week != 2: + all_posts.extend(WEEK_1) + if args.week != 1: + all_posts.extend(WEEK_2) + posts = all_posts[args.skip:] + + print(f"\n=== X Launch Campaign — {len(posts)} posts ===\n") + + if args.dry_run: + for i, post in enumerate(posts, 1): + has_media = "📎" if post["media"] else " " + print(f" {i:2d}. {has_media} {post['label']}") + print(f" Schedule: {post['time']}") + preview = post["content"][:80].replace("\n", " ") + print(f" Content: {preview}...") + if post["media"]: + print(f" Media: {post['media']}") + print() + print(f"Dry run complete — {len(posts)} posts would be scheduled.") + return + + # Upload media files first (deduplicate), with disk cache + cache_file = SCRIPT_DIR / "social_posts" / "x_launch" / ".upload_cache.json" + media_cache = {} + if cache_file.exists(): + media_cache = json.loads(cache_file.read_text()) + print(f"Loaded {len(media_cache)} cached uploads from previous run\n") + + media_files = set(p["media"] for p in posts if p["media"]) + to_upload = [m for m in sorted(media_files) if m not in media_cache] + + if to_upload: + print(f"Uploading {len(to_upload)} new media files ({len(media_files) - len(to_upload)} cached)...\n") + for media_path in to_upload: + full_path = SCRIPT_DIR / media_path + if not full_path.exists(): + print(f" ✗ {media_path} — FILE NOT FOUND, skipping") + continue + print(f" Uploading {media_path}...", end=" ", flush=True) + result = upload_file(full_path) + if result: + media_cache[media_path] = result + cache_file.write_text(json.dumps(media_cache, indent=2)) + print("✓") + else: + print("✗ FAILED") + time.sleep(3) + else: + print(f"All {len(media_files)} media files already cached, skipping uploads\n") + + # Schedule posts + print(f"\nScheduling {len(posts)} posts to X...\n") + + success = 0 + failed = 0 + for i, post in enumerate(posts, 1): + media = media_cache.get(post["media"]) if post["media"] else None + + if post["media"] and not media: + print(f" {i:2d}. ✗ {post['label']} — media upload missing, skipping") + failed += 1 + continue + + print(f" {i:2d}. Scheduling {post['label']}...", end=" ", flush=True) + result = schedule_post(post["content"], media, post["time"]) + if result: + print("✓") + success += 1 + else: + print("✗") + failed += 1 + # Rate limit: pause between API calls + if i < len(posts): + time.sleep(5) + + print(f"\n{'='*50}") + print(f"Scheduled: {success}/{len(posts)}") + if failed: + print(f"Failed: {failed}") + print(f"\nPosts will appear on @lukeattheroost starting Mon March 17") + + +if __name__ == "__main__": + main()