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