This commit is contained in:
Guillem Hernandez Sola
2026-05-08 14:07:49 +02:00
parent ad64a900e4
commit e748c05156

View File

@@ -1,13 +1,13 @@
#!/usr/bin/env python3
"""
bsky_post.py — Post text + optional image or video to a Bluesky instance.
bsky_post.py — Post text + optional image or video to Bluesky.
Usage examples:
python3 bsky_post.py "DIVENDRES!!!!" --video friday.mp4
python3 bsky_post.py "Dijous!!!!" --image thursday.jpg
python3 bsky_post.py "Bon dia!"
python3 bsky_post.py "Bon dia!" --image photo.jpg --lang ca
python3 bsky_post.py "Long video!" --video clip.mp4 --video-settle-delay 25
python3 bsky_post.py "DIVENDRES!!!!" --video friday.mp4 --username you --password app-pass
python3 bsky_post.py "Dijous!!!!" --image thursday.jpg --username you --password app-pass
python3 bsky_post.py "Bon dia!" --username you --password app-pass
python3 bsky_post.py "Bon dia!" --image photo.jpg --lang ca --username you --password app-pass
python3 bsky_post.py "Long video!" --video clip.mp4 --username you --password app-pass
"""
import argparse
@@ -33,7 +33,7 @@ class RetryConfig:
login_max_attempts: int = 5
login_base_delay_seconds: float = 10.0
login_max_delay_seconds: float = 600.0
login_jitter_seconds: float = 3
login_jitter_seconds: float = 3.0
# ============================================================
@@ -41,7 +41,7 @@ class RetryConfig:
# ============================================================
def setup_logging() -> None:
logging.basicConfig(
format="%(asctime)s %(message)s",
format="%(asctime)s %(levelname)s %(message)s",
level=logging.INFO,
stream=sys.stdout,
)
@@ -217,9 +217,7 @@ def login_with_backoff(
) -> bool:
for attempt in range(1, max_attempts + 1):
try:
logging.info(
f"🔑 Login attempt {attempt}/{max_attempts}{service_url} as {username}"
)
logging.info(f"🔑 Login attempt {attempt}/{max_attempts}{service_url} as {username}")
client.login(username, password)
logging.info("✅ Login successful.")
return True
@@ -271,26 +269,16 @@ def login_with_backoff(
# ============================================================
# PDS detection
# Utility
# ============================================================
def is_official_bsky_pds(service_url: str) -> bool:
"""
Detect whether the configured PDS is part of the official Bluesky-operated
network. Self-hosted/federated PDSes (e.g. eurosky.social) return False.
"""
try:
host = (urlparse(service_url).hostname or "").lower()
return (
host in {"bsky.social", "bsky.app"}
or host.endswith(".bsky.network")
)
return host in {"bsky.social", "bsky.app"} or host.endswith(".bsky.network")
except Exception:
return False
# ============================================================
# Media upload — Image
# ============================================================
def detect_mime_type(path: str) -> str:
mime, _ = mimetypes.guess_type(path)
if mime:
@@ -306,6 +294,25 @@ def detect_mime_type(path: str) -> str:
return fallbacks.get(ext, "application/octet-stream")
def wait_with_heartbeat(total_seconds: float, label: str = "indexing") -> None:
if total_seconds <= 0:
return
logging.info(f"⏳ Waiting {total_seconds:.0f}s for {label}...")
remaining = total_seconds
while remaining > 0:
step = min(5.0, remaining)
time.sleep(step)
remaining -= step
if remaining > 0:
logging.info(f" ...still waiting ({remaining:.0f}s remaining)...")
logging.info("✅ Wait complete.")
# ============================================================
# Media upload — Image
# ============================================================
def upload_image(
client: Client,
image_path: str,
@@ -316,7 +323,7 @@ def upload_image(
with open(image_path, "rb") as f:
data = f.read()
logging.info(f"🖼️ Uploading image: {image_path} ({len(data)/1024:.1f} KB, {mime})")
logging.info(f"🖼️ Uploading image: {image_path} ({len(data)/1024:.1f} KB, {mime})")
response = client.upload_blob(data)
logging.info("✅ Image uploaded successfully.")
@@ -331,49 +338,18 @@ def upload_image(
# ============================================================
# Helpers — settle delay
# ============================================================
def wait_with_heartbeat(total_seconds: float, label: str = "indexing") -> None:
"""
Sleep `total_seconds` while logging a heartbeat every 5s so the operator
can see we're still alive and how much time is left.
"""
if total_seconds <= 0:
return
logging.info(f"⏳ Waiting {total_seconds:.0f}s for PDS {label}...")
remaining = total_seconds
while remaining > 0:
step = min(5.0, remaining)
time.sleep(step)
remaining -= step
if remaining > 0:
logging.info(f" ...still waiting ({remaining:.0f}s remaining)...")
logging.info("✅ Settle delay complete.")
# ============================================================
# Media upload — Video (PDS-direct)
# Media upload — Video (PDS-direct fallback only)
# ============================================================
def upload_video_via_pds(
client: Client,
video_path: str,
alt_text: str = "",
settle_delay_seconds: float = 60.0,
settle_delay_seconds: float = 30.0,
) -> models.AppBskyEmbedVideo.Main | None:
"""
Upload a video as a generic blob directly to the user's PDS.
Used for self-hosted / federated PDSes (e.g. eurosky.social) that don't
proxy to the centralized video.bsky.app service. The PDS stores the bytes
and returns a blob ref we can embed directly.
A settle delay is applied AFTER the upload returns, because some PDSes
(notably non-Bluesky-operated ones) index the blob asynchronously. If we
publish the post immediately, the AppView can render "video not found"
until indexing catches up — the delay ensures the post references a blob
that's already retrievable.
Direct upload to home PDS using upload_blob.
This can produce posts where blob exists but AppView playback is unreliable.
Use only as explicit fallback.
"""
try:
if not os.path.exists(video_path):
@@ -384,16 +360,15 @@ def upload_video_via_pds(
video_bytes = f.read()
size_mb = len(video_bytes) / (1024 * 1024)
logging.info(
f"🎬 [PDS-direct] Uploading video to home PDS: {video_path} ({size_mb:.2f} MB)"
logging.warning(
f"🎬 [PDS-direct fallback] Uploading to home PDS: {video_path} ({size_mb:.2f} MB)"
)
response = client.upload_blob(video_bytes)
blob = response.blob
logging.info(" [PDS-direct] Video blob uploaded successfully.")
logging.warning("⚠️ [PDS-direct fallback] Video blob uploaded.")
# Give PDS / AppView time to index the blob before we reference it.
wait_with_heartbeat(settle_delay_seconds, label="to index the video blob")
wait_with_heartbeat(settle_delay_seconds, label="PDS/AppView indexing the video blob")
return models.AppBskyEmbedVideo.Main(
video=blob,
@@ -406,16 +381,25 @@ def upload_video_via_pds(
# ============================================================
# Media upload — Video (video.bsky.app shared service)
# Media upload — Video (video.bsky.app primary path)
# ============================================================
def _extract_service_auth_token(upload_auth) -> str | None:
token = getattr(upload_auth, "token", None)
if token:
return token
if isinstance(upload_auth, dict):
return upload_auth.get("token")
return None
def upload_video_via_bsky_service(
client: Client,
video_path: str,
alt_text: str = "",
) -> models.AppBskyEmbedVideo.Main | None:
"""
Upload a video via the centralized video.bsky.app service.
Used only when the configured PDS is on the official Bluesky network.
Upload a video via centralized video.bsky.app service.
This is the reliable playback path for Bluesky clients.
"""
try:
if not os.path.exists(video_path):
@@ -426,18 +410,30 @@ def upload_video_via_bsky_service(
video_bytes = f.read()
size_mb = len(video_bytes) / (1024 * 1024)
logging.info(
f"🎬 [video.bsky.app] Uploading via shared service: {video_path} ({size_mb:.2f} MB)"
)
logging.info(f"🎬 [video.bsky.app] Uploading: {video_path} ({size_mb:.2f} MB)")
VIDEO_HOST = "https://video.bsky.app"
VIDEO_DID = "did:web:video.bsky.app"
VIDEO_DID = "did:web:video.bsky.app"
upload_auth = client.com.atproto.server.get_service_auth({
"aud": VIDEO_DID,
"lxm": "app.bsky.video.uploadVideo",
"exp": int(time.time()) + 60 * 30,
})
# Robust params typing across atproto versions
try:
params = models.ComAtprotoServerGetServiceAuth.Params(
aud=VIDEO_DID,
lxm="app.bsky.video.uploadVideo",
exp=int(time.time()) + 60 * 30,
)
upload_auth = client.com.atproto.server.get_service_auth(params)
except Exception:
upload_auth = client.com.atproto.server.get_service_auth({
"aud": VIDEO_DID,
"lxm": "app.bsky.video.uploadVideo",
"exp": int(time.time()) + 60 * 30,
})
token = _extract_service_auth_token(upload_auth)
if not token:
logging.error("❌ Failed to get service auth token for video.bsky.app.")
return None
user_did = client.me.did
upload_url = (
@@ -445,11 +441,11 @@ def upload_video_via_bsky_service(
f"?did={user_did}&name={int(time.time())}.mp4"
)
headers = {
"Authorization": f"Bearer {upload_auth.token}",
"Content-Type": "video/mp4",
"Authorization": f"Bearer {token}",
"Content-Type": "video/mp4",
}
upload_resp = requests.post(upload_url, headers=headers, data=video_bytes, timeout=120)
upload_resp = requests.post(upload_url, headers=headers, data=video_bytes, timeout=180)
if upload_resp.status_code != 200:
logging.error(
f"❌ video.bsky.app upload failed: "
@@ -457,15 +453,16 @@ def upload_video_via_bsky_service(
)
return None
job_id = upload_resp.json().get("jobId")
payload = upload_resp.json()
job_id = payload.get("jobId")
if not job_id:
logging.error("❌ No jobId returned from video service.")
logging.error(f"❌ No jobId returned from video service. Response: {payload}")
return None
logging.info(f"⏳ Job {job_id} accepted — polling status...")
status_url = f"{VIDEO_HOST}/xrpc/app.bsky.video.getJobStatus"
deadline = time.time() + 300
deadline = time.time() + 600 # 10 min for big videos
while time.time() < deadline:
status_resp = requests.get(status_url, params={"jobId": job_id}, timeout=30)
@@ -476,32 +473,34 @@ def upload_video_via_bsky_service(
)
return None
job_status = status_resp.json().get("jobStatus", {})
status_json = status_resp.json()
job_status = status_json.get("jobStatus", {})
state = job_status.get("state")
if state == "JOB_STATE_COMPLETED":
logging.info("✅ Processing complete! Waiting 10s for CDN propagation...")
time.sleep(10)
blob_dict = job_status.get("blob")
if not blob_dict:
logging.error("❌ No blob in completed job status.")
logging.error(f"❌ No blob in completed job status: {status_json}")
return None
# Small propagation cushion
wait_with_heartbeat(8, label="CDN propagation")
blob_ref = models.BlobRef.from_dict(blob_dict)
logging.info("✅ Video processed and blob returned.")
return models.AppBskyEmbedVideo.Main(
video=blob_ref,
alt=alt_text,
)
if state == "JOB_STATE_FAILED":
logging.error(f"❌ Video processing failed on Bluesky's servers: {job_status}")
logging.error(f"❌ Video processing failed: {job_status}")
return None
logging.info(" ...still processing...")
logging.info(f" ...still processing (state={state})...")
time.sleep(3)
logging.error("❌ Video processing timed out after 5 minutes.")
logging.error("❌ Video processing timed out.")
return None
except Exception as e:
@@ -510,39 +509,50 @@ def upload_video_via_bsky_service(
# ============================================================
# Media upload — Video (smart dispatcher)
# Media upload — Video dispatcher
# ============================================================
def upload_video_smart(
client: Client,
video_path: str,
service_url: str,
alt_text: str = "",
settle_delay_seconds: float = 15.0,
settle_delay_seconds: float = 30.0,
allow_pds_video_fallback: bool = False,
) -> models.AppBskyEmbedVideo.Main | None:
"""
Smart dispatcher:
* Self-hosted / federated PDSes → direct PDS upload (with settle delay)
* Official Bluesky network → video.bsky.app, fallback to PDS-direct
Reliable policy:
1) Always try video.bsky.app first (best playback compatibility).
2) Optional direct-PDS fallback only if explicitly enabled.
"""
if is_official_bsky_pds(service_url):
logging.info(f"🌐 Detected official Bluesky PDS ({service_url}) — using video.bsky.app")
embed = upload_video_via_bsky_service(client, video_path, alt_text=alt_text)
if embed:
return embed
logging.info(f"🌐 PDS appears official ({service_url}). Using video.bsky.app.")
else:
logging.info(
f"🌍 PDS is self-hosted/federated ({service_url}). "
"Still trying video.bsky.app first for client playback reliability."
)
embed = upload_video_via_bsky_service(client, video_path, alt_text=alt_text)
if embed:
return embed
if allow_pds_video_fallback:
logging.warning(
"⚠️ video.bsky.app upload failed; falling back to direct PDS upload."
"⚠️ video.bsky.app upload failed; trying direct PDS fallback "
"(may produce unplayable videos in some clients)."
)
return upload_video_via_pds(
client, video_path, alt_text=alt_text,
client,
video_path,
alt_text=alt_text,
settle_delay_seconds=settle_delay_seconds,
)
logging.info(f"🌍 Detected self-hosted/federated PDS ({service_url}) — using direct upload")
return upload_video_via_pds(
client, video_path, alt_text=alt_text,
settle_delay_seconds=settle_delay_seconds,
logging.error(
"❌ video.bsky.app upload failed. Not posting unreliable direct-PDS video "
"(enable --allow-pds-video-fallback to override)."
)
return None
# ============================================================
@@ -556,7 +566,8 @@ def post_to_bsky(
video_path: str | None = None,
alt_text: str = "",
service_url: str = "https://bsky.social",
video_settle_delay: float = 15.0,
video_settle_delay: float = 30.0,
allow_pds_video_fallback: bool = False,
) -> bool:
rich_text = make_rich(text)
@@ -570,6 +581,7 @@ def post_to_bsky(
service_url=service_url,
alt_text=alt_text,
settle_delay_seconds=video_settle_delay,
allow_pds_video_fallback=allow_pds_video_fallback,
)
if not video_embed:
@@ -613,25 +625,34 @@ def main():
setup_logging()
parser = argparse.ArgumentParser(
description="Post text + optional image or video to a Bluesky instance."
description="Post text + optional image or video to Bluesky."
)
parser.add_argument("text", help="Post text content")
parser.add_argument("--username", required=True, help="Bluesky handle or email")
parser.add_argument("--password", required=True, help="Bluesky app password")
parser.add_argument("--service", default="https://bsky.social", help="Bluesky PDS URL")
parser.add_argument("--lang", default="ca", help="Comma-separated language codes (e.g. ca,es)")
parser.add_argument("--image", default=None, help="Path to image file to attach")
parser.add_argument("--video", default=None, help="Path to video file to attach")
parser.add_argument("--alt", default="", help="Alt text for media")
parser.add_argument("text", help="Post text content")
parser.add_argument("--username", required=True, help="Bluesky handle or email")
parser.add_argument("--password", required=True, help="Bluesky app password")
parser.add_argument("--service", default="https://bsky.social", help="Bluesky PDS URL")
parser.add_argument("--lang", default="ca", help="Comma-separated language codes (e.g. ca,es)")
parser.add_argument("--image", default=None, help="Path to image file to attach")
parser.add_argument("--video", default=None, help="Path to video file to attach")
parser.add_argument("--alt", default="", help="Alt text for media")
parser.add_argument(
"--video-settle-delay",
type=float,
default=15.0,
help="Seconds to wait after PDS-direct video upload before posting (default: 15)",
default=30.0,
help="Seconds to wait after direct-PDS video fallback upload before posting (default: 30)",
)
parser.add_argument(
"--allow-pds-video-fallback",
action="store_true",
help="Allow direct-PDS fallback if video.bsky.app upload fails (less reliable playback).",
)
args = parser.parse_args()
if args.image and args.video:
logging.error("❌ Use either --image or --video, not both.")
sys.exit(1)
client = Client(base_url=args.service)
success = login_with_backoff(
client,
@@ -656,6 +677,7 @@ def main():
alt_text=args.alt,
service_url=args.service,
video_settle_delay=args.video_settle_delay,
allow_pds_video_fallback=args.allow_pds_video_fallback,
)
if not post_success:
@@ -663,4 +685,4 @@ def main():
if __name__ == "__main__":
main()
main()