Track deploy, social, and Reaper scripts

Scripts already referenced from CLAUDE.md but never committed: deploy_searxng.sh
(which stands up the SearXNG instance Devon's web_search depends on),
post_milestone.py, generate_milestone_images.py, make_x_launch_assets.py,
schedule_x_launch.py, download_music.py, and the Reaper bleep-selection script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 04:23:53 -05:00
co-authored by Claude Opus 5
parent 2071ae4181
commit 9d49b013c2
7 changed files with 2011 additions and 0 deletions
+59
View File
@@ -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" <<EOF
use_default_settings: true
server:
secret_key: "$SECRET"
bind_address: "0.0.0.0"
limiter: false
image_proxy: true
search:
safe_search: 0
formats:
- html
- json
EOF
scp -P "$NAS_PORT" "$TMP" "$NAS_USER@$NAS_HOST:$DEPLOY_DIR/settings.yml"
rm "$TMP"
else
echo "==> 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"
+192
View File
@@ -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()
+148
View File
@@ -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}/")
+644
View File
@@ -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()
+271
View File
@@ -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()
+159
View File
@@ -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()
+538
View File
@@ -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()