diff --git a/bsky_post.py b/bsky_post.py index 0477f20..aac897e 100644 --- a/bsky_post.py +++ b/bsky_post.py @@ -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() \ No newline at end of file