diff --git a/twitter2bsky_daemon.py b/twitter2bsky_daemon.py index 9633bb6..b6df1a6 100644 --- a/twitter2bsky_daemon.py +++ b/twitter2bsky_daemon.py @@ -1,19 +1,4 @@ #!/usr/bin/env python3 -""" -bsky_post.py — Post text + optional image or video to Bluesky/federated PDS. - -Fixes included: -- Robust login backoff -- video.bsky.app first for videos -- Correct getServiceAuth: - aud = did:web: - lxm = com.atproto.repo.uploadBlob -- Handles 409 already_exists by reusing returned jobId -- SDK compatibility: no hard dependency on models.BlobRef -- Explicit createRecord payload so text is always a plain string -- ffmpeg compression (enabled by default, disable with --no-compress-video) -""" - import argparse import logging import mimetypes @@ -34,9 +19,6 @@ import requests from atproto import Client, models -# ============================================================ -# Config -# ============================================================ @dataclass(frozen=True) class RetryConfig: login_max_attempts: int = 5 @@ -45,9 +27,6 @@ class RetryConfig: login_jitter_seconds: float = 3.0 -# ============================================================ -# Logging -# ============================================================ def setup_logging() -> None: logging.basicConfig( format="%(asctime)s %(levelname)s %(message)s", @@ -56,113 +35,44 @@ def setup_logging() -> None: ) -# ============================================================ -# Error helpers -# ============================================================ def is_rate_limited_error(error_obj) -> bool: - text = repr(error_obj).lower() - return ( - "429" in text - or "ratelimitexceeded" in text - or "too many requests" in text - or "rate limit" in text - ) + t = repr(error_obj).lower() + return "429" in t or "ratelimit" in t or "too many requests" in t def is_auth_error(error_obj) -> bool: - text = repr(error_obj).lower() - return ( - "401" in text - or "403" in text - or "invalid identifier or password" in text - or "authenticationrequired" in text - or "invalidtoken" in text - ) + t = repr(error_obj).lower() + return "401" in t or "403" in t or "invalid identifier or password" in t def is_network_error(error_obj) -> bool: - text = repr(error_obj) - signals = [ - "ConnectError", - "RemoteProtocolError", - "ReadTimeout", - "WriteTimeout", - "TimeoutException", - "503", - "502", - "504", - "ConnectionResetError", - "InvokeTimeoutError", - ] - return any(sig in text for sig in signals) - - -def is_transient_error(error_obj) -> bool: - text = repr(error_obj) - transient_signals = [ - "InvokeTimeoutError", - "ReadTimeout", - "WriteTimeout", - "TimeoutException", - "RemoteProtocolError", - "ConnectError", - "503", - "502", - "504", - ] - return any(signal in text for signal in transient_signals) + t = repr(error_obj) + return any(x in t for x in [ + "ConnectError", "RemoteProtocolError", "ReadTimeout", "WriteTimeout", + "TimeoutException", "503", "502", "504", "ConnectionResetError", + "InvokeTimeoutError" + ]) def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: float) -> float: try: now_ts = int(time.time()) headers = getattr(error_obj, "headers", None) or {} - retry_after = headers.get("retry-after") or headers.get("Retry-After") if retry_after: return min(max(float(retry_after), 1.0), max_delay) - - x_after = headers.get("x-ratelimit-after") or headers.get("X-RateLimit-After") - if x_after: - return min(max(float(x_after), 1.0), max_delay) - reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset") if reset_value: wait_seconds = max(float(reset_value) - now_ts + 1.0, default_delay) return min(wait_seconds, max_delay) except Exception: pass - - text = repr(error_obj) - m = re.search(r"'retry-after': '(\d+)'", text, re.IGNORECASE) - if m: - return min(max(float(m.group(1)), 1.0), max_delay) - - m = re.search(r"'x-ratelimit-after': '(\d+)'", text, re.IGNORECASE) - if m: - return min(max(float(m.group(1)), 1.0), max_delay) - - m = re.search(r"'ratelimit-reset': '(\d+)'", text, re.IGNORECASE) - if m: - now_ts = int(time.time()) - wait_seconds = max(float(m.group(1)) - now_ts + 1.0, default_delay) - return min(wait_seconds, max_delay) - return default_delay -# ============================================================ -# Login -# ============================================================ def login_with_backoff( - client: Client, - username: str, - password: str, - service_url: str, - max_attempts: int = 5, - base_delay: float = 10.0, - max_delay: float = 600.0, - jitter: float = 1.5, + client: Client, username: str, password: str, service_url: str, + max_attempts: int = 5, base_delay: float = 10.0, max_delay: float = 600.0, jitter: float = 1.5 ) -> bool: for attempt in range(1, max_attempts + 1): try: @@ -172,60 +82,26 @@ def login_with_backoff( return True except Exception as e: logging.exception("❌ Login exception") - if is_auth_error(e): - logging.error("❌ Authentication failed. Check username/app-password.") + logging.error("❌ Authentication failed.") return False - if is_rate_limited_error(e): if attempt < max_attempts: - wait = get_rate_limit_wait_seconds(e, default_delay=base_delay, max_delay=max_delay) - wait += random.uniform(0, jitter) + wait = get_rate_limit_wait_seconds(e, base_delay, max_delay) + random.uniform(0, jitter) logging.warning(f"⏳ Rate limited, retrying in {wait:.1f}s...") time.sleep(wait) continue - logging.error("❌ Exhausted retries due to rate limit.") return False - - if is_network_error(e) or is_transient_error(e): + if is_network_error(e): if attempt < max_attempts: wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) - logging.warning(f"⏳ Transient/network error, retrying in {wait:.1f}s...") + logging.warning(f"⏳ Network/transient error, retrying in {wait:.1f}s...") time.sleep(wait) continue - logging.error("❌ Exhausted retries after network/transient errors.") return False - - if attempt < max_attempts: - wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) - logging.warning(f"⏳ Unknown error, retrying in {wait:.1f}s...") - time.sleep(wait) - continue - return False -# ============================================================ -# Utility -# ============================================================ -def detect_mime_type(path: str) -> str: - mime, _ = mimetypes.guess_type(path) - if mime: - return mime - ext = os.path.splitext(path)[1].lower() - fallbacks = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".mp4": "video/mp4", - ".mov": "video/quicktime", - ".webm": "video/webm", - } - return fallbacks.get(ext, "application/octet-stream") - - def wait_with_heartbeat(total_seconds: float, label: str) -> None: if total_seconds <= 0: return @@ -252,43 +128,11 @@ def random_video_name(ext: str = ".mp4") -> str: return f"{int(time.time())}_{token}{ext}" -def model_to_dict(obj): - if obj is None: - return None - if hasattr(obj, "model_dump"): - return obj.model_dump(by_alias=True, exclude_none=True) - if hasattr(obj, "dict"): - return obj.dict(by_alias=True, exclude_none=True) - return obj +def detect_mime_type(path: str) -> str: + mime, _ = mimetypes.guess_type(path) + return mime or "application/octet-stream" -def normalize_blob_for_embed(blob_dict: dict): - """ - Cross-version blob normalization: - - Use BlobRef if available - - else return raw dict (accepted by older/newer serializers) - """ - BlobRef = getattr(models, "BlobRef", None) - if BlobRef is not None: - try: - return BlobRef.from_dict(blob_dict) - except Exception: - pass - return blob_dict - - -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 - - -# ============================================================ -# ffmpeg compression -# ============================================================ def ffmpeg_exists() -> bool: return shutil.which("ffmpeg") is not None @@ -301,38 +145,31 @@ def get_video_duration_seconds(path: str) -> float | None: if not ffprobe_exists(): return None try: - cmd = [ + out = subprocess.check_output([ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", - path, - ] - out = subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True).strip() + path + ], stderr=subprocess.STDOUT, text=True).strip() return float(out) except Exception: return None def compress_video_ffmpeg( - input_path: str, - max_size_mb: float = 45.0, - crf: int = 28, - preset: str = "veryfast", - audio_bitrate_k: int = 96, + input_path: str, max_size_mb: float = 45.0, crf: int = 28, preset: str = "veryfast", audio_bitrate_k: int = 96 ) -> str | None: if not ffmpeg_exists(): - logging.error("❌ ffmpeg not found in PATH. Install ffmpeg or use --no-compress-video.") + logging.error("❌ ffmpeg not found. Install ffmpeg or use --no-compress-video.") return None - if not os.path.exists(input_path): - logging.error(f"❌ Input video not found: {input_path}") + logging.error(f"❌ Video file not found: {input_path}") return None src_size_mb = os.path.getsize(input_path) / (1024 * 1024) logging.info(f"📦 Source video size: {src_size_mb:.2f} MB") - if src_size_mb <= max_size_mb: - logging.info("✅ Already below target size. Skipping compression.") + logging.info("✅ Already below size target. Skipping compression.") return input_path duration = get_video_duration_seconds(input_path) @@ -362,23 +199,14 @@ def compress_video_ffmpeg( ] try: - logging.info( - f"🛠️ Compressing video (target≤{max_size_mb}MB, crf={crf}, preset={preset}, v≈{target_video_k}k)..." - ) + logging.info(f"🛠️ Compressing video (target≤{max_size_mb}MB, crf={crf}, preset={preset})...") subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) out_size_mb = os.path.getsize(out_path) / (1024 * 1024) logging.info(f"✅ Compressed size: {out_size_mb:.2f} MB") - if out_size_mb < src_size_mb: return out_path - - logging.info("ℹ️ Compressed file is not smaller. Using original.") - try: - os.remove(out_path) - except Exception: - pass + os.remove(out_path) return input_path - except subprocess.CalledProcessError as e: logging.error("❌ ffmpeg compression failed.") if e.stderr: @@ -390,78 +218,101 @@ def compress_video_ffmpeg( return None -# ============================================================ -# Uploads -# ============================================================ -def upload_image(client: Client, image_path: str, alt_text: str = ""): - try: - if not os.path.exists(image_path): - logging.error(f"❌ Image file not found: {image_path}") - return None +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 - mime = detect_mime_type(image_path) - with open(image_path, "rb") as f: - data = f.read() - logging.info(f"🖼️ Uploading image: {image_path} ({len(data)/1024:.1f} KB, {mime})") - response = client.upload_blob(data) - return models.AppBskyEmbedImages.Image(image=response.blob, alt=alt_text) - except Exception as e: - logging.error(f"❌ Failed to upload image: {repr(e)}") +def upload_video_via_bsky_service(client: Client, video_path: str, service_url: str, alt_text: str = "") -> dict | None: + if not os.path.exists(video_path): + logging.error(f"❌ Video file not found: {video_path}") return None + with open(video_path, "rb") as f: + video_bytes = f.read() + + size_mb = len(video_bytes) / (1024 * 1024) + logging.info(f"🎬 [video.bsky.app] Uploading: {video_path} ({size_mb:.2f} MB)") + + video_host = "https://video.bsky.app" + pds_did = pds_did_from_service_url(service_url) -def upload_video_via_pds( - client: Client, - video_path: str, - alt_text: str = "", - settle_delay_seconds: float = 30.0, -): try: - if not os.path.exists(video_path): - logging.error(f"❌ Video file not found: {video_path}") - return None + params = models.ComAtprotoServerGetServiceAuth.Params( + aud=pds_did, + lxm="com.atproto.repo.uploadBlob", + 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": pds_did, + "lxm": "com.atproto.repo.uploadBlob", + "exp": int(time.time()) + 60 * 30, + }) - with open(video_path, "rb") as f: - video_bytes = f.read() - - size_mb = len(video_bytes) / (1024 * 1024) - logging.warning(f"🎬 [PDS-direct fallback] Uploading: {video_path} ({size_mb:.2f} MB)") - response = client.upload_blob(video_bytes) - blob = response.blob - - logging.warning("⚠️ [PDS-direct fallback] Blob uploaded. Waiting for indexing...") - wait_with_heartbeat(settle_delay_seconds, "PDS/AppView indexing") - return models.AppBskyEmbedVideo.Main(video=blob, alt=alt_text) - except Exception as e: - logging.error(f"❌ PDS-direct video upload failed: {repr(e)}") + token = _extract_service_auth_token(upload_auth) + if not token: + logging.error("❌ Could not get service auth token.") return None + upload_name = random_video_name(".mp4") + logging.info(f"🎞️ Upload name: {upload_name}") -def _poll_video_job(video_host: str, job_id: str, alt_text: str): + upload_url = f"{video_host}/xrpc/app.bsky.video.uploadVideo?did={client.me.did}&name={upload_name}" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "video/mp4"} + + resp = requests.post(upload_url, headers=headers, data=video_bytes, timeout=240) + + if resp.status_code not in (200, 409): + logging.error(f"❌ video.bsky.app upload failed: {resp.status_code} - {resp.text}") + return None + + body = resp.json() + if resp.status_code == 409: + if body.get("error") == "already_exists" and body.get("jobId"): + logging.info("ℹ️ Video already processed on video.bsky.app. Reusing existing job.") + else: + logging.error(f"❌ 409 without reusable jobId: {body}") + return None + + job_id = body.get("jobId") + if not job_id: + logging.error(f"❌ Missing jobId in upload response: {body}") + return None + + logging.info(f"⏳ Job {job_id} accepted — polling status...") status_url = f"{video_host}/xrpc/app.bsky.video.getJobStatus" deadline = time.time() + 600 while time.time() < deadline: - status_resp = requests.get(status_url, params={"jobId": job_id}, timeout=30) - if status_resp.status_code != 200: - logging.error(f"❌ Job status check failed: {status_resp.status_code} - {status_resp.text}") + s = requests.get(status_url, params={"jobId": job_id}, timeout=30) + if s.status_code != 200: + logging.error(f"❌ Job status check failed: {s.status_code} - {s.text}") return None - status_json = status_resp.json() + status_json = s.json() job_status = status_json.get("jobStatus", {}) state = job_status.get("state") if state == "JOB_STATE_COMPLETED": - blob_dict = job_status.get("blob") - if not blob_dict: - logging.error(f"❌ No blob in completed job status: {status_json}") + blob = job_status.get("blob") + if not blob: + logging.error(f"❌ Completed job without blob: {status_json}") return None wait_with_heartbeat(8, "CDN propagation") - blob_obj = normalize_blob_for_embed(blob_dict) # <- BlobRef-safe - logging.info("✅ Video processed successfully.") - return models.AppBskyEmbedVideo.Main(video=blob_obj, alt=alt_text) + + # Return RAW embed dict (no models.BlobRef dependency) + return { + "$type": "app.bsky.embed.video", + "video": blob, + "alt": alt_text or "", + } if state == "JOB_STATE_FAILED": logging.error(f"❌ Video processing failed: {job_status}") @@ -474,217 +325,160 @@ def _poll_video_job(video_host: str, job_id: str, alt_text: str): return None -def upload_video_via_bsky_service( - client: Client, - video_path: str, - service_url: str, - alt_text: str = "", -): +def upload_video_via_pds(client: Client, video_path: str, alt_text: str = "", settle_delay_seconds: float = 30.0) -> dict | None: try: - if not os.path.exists(video_path): - logging.error(f"❌ Video file not found: {video_path}") - return None - with open(video_path, "rb") as f: video_bytes = f.read() size_mb = len(video_bytes) / (1024 * 1024) - logging.info(f"🎬 [video.bsky.app] Uploading: {video_path} ({size_mb:.2f} MB)") + logging.warning(f"🎬 [PDS-direct fallback] Uploading: {video_path} ({size_mb:.2f} MB)") + r = client.upload_blob(video_bytes) - video_host = "https://video.bsky.app" - pds_did = pds_did_from_service_url(service_url) + wait_with_heartbeat(settle_delay_seconds, "PDS/AppView indexing") + blob = getattr(r, "blob", None) + if blob is None: + d = r if isinstance(r, dict) else {} + blob = d.get("blob") - # getServiceAuth with correct aud + lxm - try: - params = models.ComAtprotoServerGetServiceAuth.Params( - aud=pds_did, - lxm="com.atproto.repo.uploadBlob", - 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": pds_did, - "lxm": "com.atproto.repo.uploadBlob", - "exp": int(time.time()) + 60 * 30, - } - ) - - token = _extract_service_auth_token(upload_auth) - if not token: - logging.error("❌ Failed to extract service auth token.") + if blob is None: + logging.error("❌ PDS uploadBlob returned no blob.") return None - upload_name = random_video_name(".mp4") - logging.info(f"🎞️ Upload name: {upload_name}") - - upload_url = ( - f"{video_host}/xrpc/app.bsky.video.uploadVideo" - f"?did={client.me.did}&name={upload_name}" - ) - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "video/mp4", + # Also return raw embed dict + return { + "$type": "app.bsky.embed.video", + "video": blob, + "alt": alt_text or "", } - - upload_resp = requests.post(upload_url, headers=headers, data=video_bytes, timeout=240) - - if upload_resp.status_code not in (200, 409): - logging.error(f"❌ video.bsky.app upload failed: {upload_resp.status_code} - {upload_resp.text}") - return None - - body = upload_resp.json() - - if upload_resp.status_code == 409: - if body.get("error") == "already_exists" and body.get("jobId"): - logging.info("ℹ️ Video already processed on video.bsky.app. Reusing existing job.") - else: - logging.error(f"❌ 409 without reusable jobId: {body}") - return None - - job_id = body.get("jobId") - if not job_id: - logging.error(f"❌ No jobId in upload response: {body}") - return None - - logging.info(f"⏳ Job {job_id} accepted — polling status...") - return _poll_video_job(video_host, job_id, alt_text) - except Exception as e: - logging.error(f"❌ video.bsky.app upload failed: {repr(e)}") + logging.error(f"❌ PDS-direct video upload failed: {repr(e)}") return None def upload_video_smart( - client: Client, - video_path: str, - service_url: str, - alt_text: str = "", - settle_delay_seconds: float = 30.0, - allow_pds_video_fallback: bool = False, -): + client: Client, video_path: str, service_url: str, alt_text: str, + settle_delay_seconds: float, allow_pds_video_fallback: bool +) -> dict | None: logging.info(f"🌍 PDS ({service_url}). Trying video.bsky.app first.") - embed = upload_video_via_bsky_service( - client=client, - video_path=video_path, - service_url=service_url, - alt_text=alt_text, - ) + embed = upload_video_via_bsky_service(client, video_path, service_url, alt_text) if embed: return embed if allow_pds_video_fallback: logging.warning("⚠️ video.bsky.app failed; trying direct PDS fallback.") - return upload_video_via_pds( - client=client, - video_path=video_path, - alt_text=alt_text, - settle_delay_seconds=settle_delay_seconds, - ) + return upload_video_via_pds(client, video_path, alt_text, settle_delay_seconds) logging.error("❌ video.bsky.app failed and fallback disabled.") return None -# ============================================================ -# Posting -# ============================================================ +def upload_image(client: Client, image_path: str, alt_text: str = "") -> dict | None: + try: + if not os.path.exists(image_path): + logging.error(f"❌ Image file not found: {image_path}") + return None + mime = detect_mime_type(image_path) + with open(image_path, "rb") as f: + data = f.read() + logging.info(f"🖼️ Uploading image: {image_path} ({len(data)/1024:.1f} KB, {mime})") + r = client.upload_blob(data) + blob = getattr(r, "blob", None) + if blob is None and isinstance(r, dict): + blob = r.get("blob") + if blob is None: + logging.error("❌ uploadBlob returned no blob for image.") + return None + + return { + "$type": "app.bsky.embed.images", + "images": [{"alt": alt_text or "", "image": blob}], + } + except Exception as e: + logging.error(f"❌ Failed to upload image: {repr(e)}") + return None + + def post_to_bsky( client: Client, text: str, langs: list[str], - image_path: str | None = None, - video_path: str | None = None, - alt_text: str = "", - service_url: str = "https://bsky.social", - video_settle_delay: float = 30.0, - allow_pds_video_fallback: bool = False, + image_path: str | None, + video_path: str | None, + alt_text: str, + service_url: str, + video_settle_delay: float, + allow_pds_video_fallback: bool, ) -> bool: post_text = text.strip() - if not post_text and not image_path and not video_path: logging.error("❌ Empty post text with no media is not allowed.") return False + embed_dict = None + if video_path: + logging.info(f"🎬 Preparing video upload: {video_path}") + embed_dict = upload_video_smart( + client=client, + video_path=video_path, + service_url=service_url, + alt_text=alt_text, + settle_delay_seconds=video_settle_delay, + allow_pds_video_fallback=allow_pds_video_fallback, + ) + if not embed_dict: + logging.error("❌ Aborting post: video upload/processing failed.") + return False + elif image_path: + embed_dict = upload_image(client, image_path, alt_text) + if not embed_dict: + logging.error("❌ Aborting post: image upload failed.") + return False + + record = { + "$type": "app.bsky.feed.post", + "text": post_text, + "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), + } + if langs: + record["langs"] = langs + if embed_dict: + record["embed"] = embed_dict + + logging.info(f"🧾 Final record text={record.get('text')!r}, has_embed={'embed' in record}") + try: - embed_obj = None + data = models.ComAtprotoRepoCreateRecord.Data( + repo=client.me.did, + collection="app.bsky.feed.post", + record=record, + ) + resp = client.com.atproto.repo.create_record(data) + except Exception: + resp = client.com.atproto.repo.create_record({ + "repo": client.me.did, + "collection": "app.bsky.feed.post", + "record": record, + }) - if video_path: - logging.info(f"🎬 Preparing video upload: {video_path}") - embed_obj = upload_video_smart( - client=client, - video_path=video_path, - service_url=service_url, - alt_text=alt_text, - settle_delay_seconds=video_settle_delay, - allow_pds_video_fallback=allow_pds_video_fallback, - ) - if not embed_obj: - logging.error("❌ Aborting post: video upload/processing failed.") - return False - - elif image_path: - image = upload_image(client, image_path, alt_text) - if not image: - logging.error("❌ Aborting post: image upload failed.") - return False - embed_obj = models.AppBskyEmbedImages.Main(images=[image]) - - record = { - "$type": "app.bsky.feed.post", - "text": post_text, - "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), - } - - if langs: - record["langs"] = langs - if embed_obj is not None: - record["embed"] = model_to_dict(embed_obj) - - logging.info(f"🧾 Final record text={record.get('text')!r}, has_embed={'embed' in record}") - - try: - resp = client.com.atproto.repo.create_record( - models.ComAtprotoRepoCreateRecord.Data( - repo=client.me.did, - collection="app.bsky.feed.post", - record=record, - ) - ) - except Exception: - resp = client.com.atproto.repo.create_record( - {"repo": client.me.did, "collection": "app.bsky.feed.post", "record": record} - ) - - uri = getattr(resp, "uri", None) or (resp.get("uri") if isinstance(resp, dict) else None) - logging.info(f"✅ Post published! URI: {uri}") - return True - - except Exception as e: - logging.error(f"❌ Failed to send post: {repr(e)}") - return False + uri = getattr(resp, "uri", None) or (resp.get("uri") if isinstance(resp, dict) else None) + logging.info(f"✅ Post published! URI: {uri}") + return True -# ============================================================ -# CLI -# ============================================================ def main(): setup_logging() - parser = argparse.ArgumentParser(description="Post text + optional image/video to Bluesky/federated PDS.") parser.add_argument("text", help="Post text content") - parser.add_argument("--username", required=True, help="Bluesky handle/email") - parser.add_argument("--password", required=True, help="Bluesky app password") - parser.add_argument("--service", default="https://bsky.social", help="PDS URL") - parser.add_argument("--lang", default="ca", help="Comma-separated language codes") - parser.add_argument("--image", default=None, help="Path to image file") - parser.add_argument("--video", default=None, help="Path to video file") - parser.add_argument("--alt", default="", help="Alt text for media") + parser.add_argument("--username", required=True) + parser.add_argument("--password", required=True) + parser.add_argument("--service", default="https://bsky.social") + parser.add_argument("--lang", default="ca") + parser.add_argument("--image", default=None) + parser.add_argument("--video", default=None) + parser.add_argument("--alt", default="") parser.add_argument("--video-settle-delay", type=float, default=30.0) parser.add_argument("--allow-pds-video-fallback", action="store_true") - # Compression defaults ON parser.add_argument("--compress-video", dest="compress_video", action="store_true", default=True) parser.add_argument("--no-compress-video", dest="compress_video", action="store_false") parser.add_argument("--max-video-mb", type=float, default=45.0) @@ -698,24 +492,19 @@ def main(): sys.exit(1) client = Client(base_url=args.service) - if not login_with_backoff( - client=client, - username=args.username, - password=args.password, - service_url=args.service, - max_attempts=RetryConfig.login_max_attempts, - base_delay=RetryConfig.login_base_delay_seconds, - max_delay=RetryConfig.login_max_delay_seconds, - jitter=RetryConfig.login_jitter_seconds, + client, args.username, args.password, args.service, + RetryConfig.login_max_attempts, + RetryConfig.login_base_delay_seconds, + RetryConfig.login_max_delay_seconds, + RetryConfig.login_jitter_seconds, ): sys.exit(1) - langs = [l.strip() for l in args.lang.split(",") if l.strip()] + langs = [x.strip() for x in args.lang.split(",") if x.strip()] video_path_for_upload = args.video temp_compressed_path = None - if args.video and args.compress_video: compressed = compress_video_ffmpeg( input_path=args.video,