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