diff --git a/bsky_post.py b/bsky_post.py index 3f4bc9e..5103d7c 100644 --- a/bsky_post.py +++ b/bsky_post.py @@ -7,15 +7,7 @@ Usage examples: 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 - -Notes: - * Self-hosted / federated PDSes (e.g. eurosky.social) upload videos - DIRECTLY to the user's PDS as a generic blob — no video.bsky.app - service auth is required. - * The official Bluesky network (bsky.social, *.bsky.network) uses the - centralized video.bsky.app pipeline with a service-auth token scoped - via `lxm` to `app.bsky.video.uploadVideo`. - * The script auto-detects which path to use based on --service. + python3 bsky_post.py "Long video!" --video clip.mp4 --video-settle-delay 25 """ import argparse @@ -153,9 +145,6 @@ def is_transient_error(error_obj) -> bool: def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: float) -> float: - """ - Parse common rate-limit headers and return a bounded wait time in seconds. - """ try: now_ts = int(time.time()) @@ -341,6 +330,29 @@ def upload_image( return None +# ============================================================ +# 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) # ============================================================ @@ -348,6 +360,7 @@ def upload_video_via_pds( client: Client, video_path: str, alt_text: str = "", + settle_delay_seconds: float = 15.0, ) -> models.AppBskyEmbedVideo.Main | None: """ Upload a video as a generic blob directly to the user's PDS. @@ -355,6 +368,12 @@ def upload_video_via_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. """ try: if not os.path.exists(video_path): @@ -365,11 +384,16 @@ 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.info( + f"🎬 [PDS-direct] Uploading video to home PDS: {video_path} ({size_mb:.2f} MB)" + ) response = client.upload_blob(video_bytes) blob = response.blob - logging.info(f"✅ [PDS-direct] Video blob uploaded successfully.") + logging.info("✅ [PDS-direct] Video blob uploaded successfully.") + + # Give PDS / AppView time to index the blob before we reference it. + wait_with_heartbeat(settle_delay_seconds, label="to index the video blob") return models.AppBskyEmbedVideo.Main( video=blob, @@ -402,12 +426,13 @@ 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 via shared service: {video_path} ({size_mb:.2f} MB)" + ) VIDEO_HOST = "https://video.bsky.app" VIDEO_DID = "did:web:video.bsky.app" - # --- Service auth token (lxm-scoped → 30 min OK) --- upload_auth = client.com.atproto.server.get_service_auth({ "aud": VIDEO_DID, "lxm": "app.bsky.video.uploadVideo", @@ -439,9 +464,8 @@ def upload_video_via_bsky_service( logging.info(f"⏳ Job {job_id} accepted — polling status...") - # getJobStatus is unauthenticated on video.bsky.app status_url = f"{VIDEO_HOST}/xrpc/app.bsky.video.getJobStatus" - deadline = time.time() + 300 # 5-minute ceiling + deadline = time.time() + 300 while time.time() < deadline: status_resp = requests.get(status_url, params={"jobId": job_id}, timeout=30) @@ -493,14 +517,12 @@ def upload_video_smart( video_path: str, service_url: str, alt_text: str = "", + settle_delay_seconds: float = 15.0, ) -> models.AppBskyEmbedVideo.Main | None: """ - Smart dispatcher: pick PDS-direct or video.bsky.app based on the PDS. - - * Self-hosted / federated PDSes (eurosky.social etc.) → direct PDS upload - (no service auth needed; PDS stores video itself). - * Official Bluesky network (bsky.social, *.bsky.network) → video.bsky.app - with lxm-scoped service auth, falling back to PDS-direct on failure. + Smart dispatcher: + * Self-hosted / federated PDSes → direct PDS upload (with settle delay) + * Official Bluesky network → video.bsky.app, fallback to PDS-direct """ if is_official_bsky_pds(service_url): logging.info(f"🌐 Detected official Bluesky PDS ({service_url}) — using video.bsky.app") @@ -511,10 +533,16 @@ def upload_video_smart( logging.warning( "⚠️ video.bsky.app upload failed; falling back to direct PDS upload." ) - return upload_video_via_pds(client, video_path, alt_text=alt_text) + return upload_video_via_pds( + 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) + return upload_video_via_pds( + client, video_path, alt_text=alt_text, + settle_delay_seconds=settle_delay_seconds, + ) # ============================================================ @@ -528,11 +556,11 @@ def post_to_bsky( video_path: str | None = None, alt_text: str = "", service_url: str = "https://bsky.social", + video_settle_delay: float = 15.0, ) -> bool: rich_text = make_rich(text) try: - # --- VIDEO POSTING --- if video_path: logging.info(f"🎬 Preparing video upload: {video_path}") @@ -541,6 +569,7 @@ def post_to_bsky( video_path, service_url=service_url, alt_text=alt_text, + settle_delay_seconds=video_settle_delay, ) if not video_embed: @@ -554,7 +583,6 @@ def post_to_bsky( langs=langs, ) - # --- IMAGE POSTING --- elif image_path: image = upload_image(client, image_path, alt_text=alt_text) if not image: @@ -565,7 +593,6 @@ def post_to_bsky( logging.info("🚀 Sending image post...") result = client.send_post(text=rich_text, embed=embed, langs=langs) - # --- TEXT ONLY POSTING --- else: logging.info("🚀 Sending text post...") result = client.send_post(text=rich_text, langs=langs) @@ -596,6 +623,12 @@ def main(): 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)", + ) args = parser.parse_args() @@ -622,6 +655,7 @@ def main(): video_path=args.video, alt_text=args.alt, service_url=args.service, + video_settle_delay=args.video_settle_delay, ) if not post_success: @@ -629,4 +663,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main()