More fixes

This commit is contained in:
2026-05-08 16:57:21 +00:00
parent d497979e19
commit 3fd63571fd

View File

@@ -1,19 +1,4 @@
#!/usr/bin/env python3 #!/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:<your-pds-host>
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 argparse
import logging import logging
import mimetypes import mimetypes
@@ -34,9 +19,6 @@ import requests
from atproto import Client, models from atproto import Client, models
# ============================================================
# Config
# ============================================================
@dataclass(frozen=True) @dataclass(frozen=True)
class RetryConfig: class RetryConfig:
login_max_attempts: int = 5 login_max_attempts: int = 5
@@ -45,9 +27,6 @@ class RetryConfig:
login_jitter_seconds: float = 3.0 login_jitter_seconds: float = 3.0
# ============================================================
# Logging
# ============================================================
def setup_logging() -> None: def setup_logging() -> None:
logging.basicConfig( logging.basicConfig(
format="%(asctime)s %(levelname)s %(message)s", 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: def is_rate_limited_error(error_obj) -> bool:
text = repr(error_obj).lower() t = repr(error_obj).lower()
return ( return "429" in t or "ratelimit" in t or "too many requests" in t
"429" in text
or "ratelimitexceeded" in text
or "too many requests" in text
or "rate limit" in text
)
def is_auth_error(error_obj) -> bool: def is_auth_error(error_obj) -> bool:
text = repr(error_obj).lower() t = repr(error_obj).lower()
return ( return "401" in t or "403" in t or "invalid identifier or password" in t
"401" in text
or "403" in text
or "invalid identifier or password" in text
or "authenticationrequired" in text
or "invalidtoken" in text
)
def is_network_error(error_obj) -> bool: def is_network_error(error_obj) -> bool:
text = repr(error_obj) t = repr(error_obj)
signals = [ return any(x in t for x in [
"ConnectError", "ConnectError", "RemoteProtocolError", "ReadTimeout", "WriteTimeout",
"RemoteProtocolError", "TimeoutException", "503", "502", "504", "ConnectionResetError",
"ReadTimeout", "InvokeTimeoutError"
"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)
def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: float) -> float: def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: float) -> float:
try: try:
now_ts = int(time.time()) now_ts = int(time.time())
headers = getattr(error_obj, "headers", None) or {} headers = getattr(error_obj, "headers", None) or {}
retry_after = headers.get("retry-after") or headers.get("Retry-After") retry_after = headers.get("retry-after") or headers.get("Retry-After")
if retry_after: if retry_after:
return min(max(float(retry_after), 1.0), max_delay) 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") reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset")
if reset_value: if reset_value:
wait_seconds = max(float(reset_value) - now_ts + 1.0, default_delay) wait_seconds = max(float(reset_value) - now_ts + 1.0, default_delay)
return min(wait_seconds, max_delay) return min(wait_seconds, max_delay)
except Exception: except Exception:
pass 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 return default_delay
# ============================================================
# Login
# ============================================================
def login_with_backoff( def login_with_backoff(
client: Client, client: Client, username: str, password: str, service_url: str,
username: str, max_attempts: int = 5, base_delay: float = 10.0, max_delay: float = 600.0, jitter: float = 1.5
password: str,
service_url: str,
max_attempts: int = 5,
base_delay: float = 10.0,
max_delay: float = 600.0,
jitter: float = 1.5,
) -> bool: ) -> bool:
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
try: try:
@@ -172,60 +82,26 @@ def login_with_backoff(
return True return True
except Exception as e: except Exception as e:
logging.exception("❌ Login exception") logging.exception("❌ Login exception")
if is_auth_error(e): if is_auth_error(e):
logging.error("❌ Authentication failed. Check username/app-password.") logging.error("❌ Authentication failed.")
return False return False
if is_rate_limited_error(e): if is_rate_limited_error(e):
if attempt < max_attempts: if attempt < max_attempts:
wait = get_rate_limit_wait_seconds(e, default_delay=base_delay, max_delay=max_delay) wait = get_rate_limit_wait_seconds(e, base_delay, max_delay) + random.uniform(0, jitter)
wait += random.uniform(0, jitter)
logging.warning(f"⏳ Rate limited, retrying in {wait:.1f}s...") logging.warning(f"⏳ Rate limited, retrying in {wait:.1f}s...")
time.sleep(wait) time.sleep(wait)
continue continue
logging.error("❌ Exhausted retries due to rate limit.")
return False return False
if is_network_error(e):
if is_network_error(e) or is_transient_error(e):
if attempt < max_attempts: if attempt < max_attempts:
wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) 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) time.sleep(wait)
continue continue
logging.error("❌ Exhausted retries after network/transient errors.")
return False 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 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: def wait_with_heartbeat(total_seconds: float, label: str) -> None:
if total_seconds <= 0: if total_seconds <= 0:
return return
@@ -252,43 +128,11 @@ def random_video_name(ext: str = ".mp4") -> str:
return f"{int(time.time())}_{token}{ext}" return f"{int(time.time())}_{token}{ext}"
def model_to_dict(obj): def detect_mime_type(path: str) -> str:
if obj is None: mime, _ = mimetypes.guess_type(path)
return None return mime or "application/octet-stream"
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 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: def ffmpeg_exists() -> bool:
return shutil.which("ffmpeg") is not None return shutil.which("ffmpeg") is not None
@@ -301,38 +145,31 @@ def get_video_duration_seconds(path: str) -> float | None:
if not ffprobe_exists(): if not ffprobe_exists():
return None return None
try: try:
cmd = [ out = subprocess.check_output([
"ffprobe", "-v", "error", "ffprobe", "-v", "error",
"-show_entries", "format=duration", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", "-of", "default=noprint_wrappers=1:nokey=1",
path, path
] ], stderr=subprocess.STDOUT, text=True).strip()
out = subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True).strip()
return float(out) return float(out)
except Exception: except Exception:
return None return None
def compress_video_ffmpeg( def compress_video_ffmpeg(
input_path: str, input_path: str, max_size_mb: float = 45.0, crf: int = 28, preset: str = "veryfast", audio_bitrate_k: int = 96
max_size_mb: float = 45.0,
crf: int = 28,
preset: str = "veryfast",
audio_bitrate_k: int = 96,
) -> str | None: ) -> str | None:
if not ffmpeg_exists(): 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 return None
if not os.path.exists(input_path): 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 return None
src_size_mb = os.path.getsize(input_path) / (1024 * 1024) src_size_mb = os.path.getsize(input_path) / (1024 * 1024)
logging.info(f"📦 Source video size: {src_size_mb:.2f} MB") logging.info(f"📦 Source video size: {src_size_mb:.2f} MB")
if src_size_mb <= max_size_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 return input_path
duration = get_video_duration_seconds(input_path) duration = get_video_duration_seconds(input_path)
@@ -362,23 +199,14 @@ def compress_video_ffmpeg(
] ]
try: try:
logging.info( logging.info(f"🛠️ Compressing video (target≤{max_size_mb}MB, crf={crf}, preset={preset})...")
f"🛠️ Compressing video (target≤{max_size_mb}MB, crf={crf}, preset={preset}, v≈{target_video_k}k)..."
)
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
out_size_mb = os.path.getsize(out_path) / (1024 * 1024) out_size_mb = os.path.getsize(out_path) / (1024 * 1024)
logging.info(f"✅ Compressed size: {out_size_mb:.2f} MB") logging.info(f"✅ Compressed size: {out_size_mb:.2f} MB")
if out_size_mb < src_size_mb: if out_size_mb < src_size_mb:
return out_path return out_path
os.remove(out_path)
logging.info(" Compressed file is not smaller. Using original.")
try:
os.remove(out_path)
except Exception:
pass
return input_path return input_path
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logging.error("❌ ffmpeg compression failed.") logging.error("❌ ffmpeg compression failed.")
if e.stderr: if e.stderr:
@@ -390,78 +218,101 @@ def compress_video_ffmpeg(
return None return None
# ============================================================ def _extract_service_auth_token(upload_auth) -> str | None:
# Uploads token = getattr(upload_auth, "token", None)
# ============================================================ if token:
def upload_image(client: Client, image_path: str, alt_text: str = ""): return token
try: if isinstance(upload_auth, dict):
if not os.path.exists(image_path): return upload_auth.get("token")
logging.error(f"❌ Image file not found: {image_path}") return None
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})") def upload_video_via_bsky_service(client: Client, video_path: str, service_url: str, alt_text: str = "") -> dict | None:
response = client.upload_blob(data) if not os.path.exists(video_path):
return models.AppBskyEmbedImages.Image(image=response.blob, alt=alt_text) logging.error(f"❌ Video file not found: {video_path}")
except Exception as e:
logging.error(f"❌ Failed to upload image: {repr(e)}")
return None 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: try:
if not os.path.exists(video_path): params = models.ComAtprotoServerGetServiceAuth.Params(
logging.error(f"❌ Video file not found: {video_path}") aud=pds_did,
return None 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: token = _extract_service_auth_token(upload_auth)
video_bytes = f.read() if not token:
logging.error("❌ Could not get service auth token.")
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)}")
return None 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" status_url = f"{video_host}/xrpc/app.bsky.video.getJobStatus"
deadline = time.time() + 600 deadline = time.time() + 600
while time.time() < deadline: while time.time() < deadline:
status_resp = requests.get(status_url, params={"jobId": job_id}, timeout=30) s = requests.get(status_url, params={"jobId": job_id}, timeout=30)
if status_resp.status_code != 200: if s.status_code != 200:
logging.error(f"❌ Job status check failed: {status_resp.status_code} - {status_resp.text}") logging.error(f"❌ Job status check failed: {s.status_code} - {s.text}")
return None return None
status_json = status_resp.json() status_json = s.json()
job_status = status_json.get("jobStatus", {}) job_status = status_json.get("jobStatus", {})
state = job_status.get("state") state = job_status.get("state")
if state == "JOB_STATE_COMPLETED": if state == "JOB_STATE_COMPLETED":
blob_dict = job_status.get("blob") blob = job_status.get("blob")
if not blob_dict: if not blob:
logging.error(f"No blob in completed job status: {status_json}") logging.error(f"Completed job without blob: {status_json}")
return None return None
wait_with_heartbeat(8, "CDN propagation") wait_with_heartbeat(8, "CDN propagation")
blob_obj = normalize_blob_for_embed(blob_dict) # <- BlobRef-safe
logging.info("✅ Video processed successfully.") # Return RAW embed dict (no models.BlobRef dependency)
return models.AppBskyEmbedVideo.Main(video=blob_obj, alt=alt_text) return {
"$type": "app.bsky.embed.video",
"video": blob,
"alt": alt_text or "",
}
if state == "JOB_STATE_FAILED": if state == "JOB_STATE_FAILED":
logging.error(f"❌ Video processing failed: {job_status}") 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 return None
def upload_video_via_bsky_service( def upload_video_via_pds(client: Client, video_path: str, alt_text: str = "", settle_delay_seconds: float = 30.0) -> dict | None:
client: Client,
video_path: str,
service_url: str,
alt_text: str = "",
):
try: 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: with open(video_path, "rb") as f:
video_bytes = f.read() video_bytes = f.read()
size_mb = len(video_bytes) / (1024 * 1024) 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" wait_with_heartbeat(settle_delay_seconds, "PDS/AppView indexing")
pds_did = pds_did_from_service_url(service_url) 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 if blob is None:
try: logging.error("❌ PDS uploadBlob returned no blob.")
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.")
return None return None
upload_name = random_video_name(".mp4") # Also return raw embed dict
logging.info(f"🎞️ Upload name: {upload_name}") return {
"$type": "app.bsky.embed.video",
upload_url = ( "video": blob,
f"{video_host}/xrpc/app.bsky.video.uploadVideo" "alt": alt_text or "",
f"?did={client.me.did}&name={upload_name}"
)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "video/mp4",
} }
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: 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 return None
def upload_video_smart( def upload_video_smart(
client: Client, client: Client, video_path: str, service_url: str, alt_text: str,
video_path: str, settle_delay_seconds: float, allow_pds_video_fallback: bool
service_url: str, ) -> dict | None:
alt_text: str = "",
settle_delay_seconds: float = 30.0,
allow_pds_video_fallback: bool = False,
):
logging.info(f"🌍 PDS ({service_url}). Trying video.bsky.app first.") logging.info(f"🌍 PDS ({service_url}). Trying video.bsky.app first.")
embed = upload_video_via_bsky_service( embed = upload_video_via_bsky_service(client, video_path, service_url, alt_text)
client=client,
video_path=video_path,
service_url=service_url,
alt_text=alt_text,
)
if embed: if embed:
return embed return embed
if allow_pds_video_fallback: if allow_pds_video_fallback:
logging.warning("⚠️ video.bsky.app failed; trying direct PDS fallback.") logging.warning("⚠️ video.bsky.app failed; trying direct PDS fallback.")
return upload_video_via_pds( return upload_video_via_pds(client, video_path, alt_text, settle_delay_seconds)
client=client,
video_path=video_path,
alt_text=alt_text,
settle_delay_seconds=settle_delay_seconds,
)
logging.error("❌ video.bsky.app failed and fallback disabled.") logging.error("❌ video.bsky.app failed and fallback disabled.")
return None return None
# ============================================================ def upload_image(client: Client, image_path: str, alt_text: str = "") -> dict | None:
# Posting 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( def post_to_bsky(
client: Client, client: Client,
text: str, text: str,
langs: list[str], langs: list[str],
image_path: str | None = None, image_path: str | None,
video_path: str | None = None, video_path: str | None,
alt_text: str = "", alt_text: str,
service_url: str = "https://bsky.social", service_url: str,
video_settle_delay: float = 30.0, video_settle_delay: float,
allow_pds_video_fallback: bool = False, allow_pds_video_fallback: bool,
) -> bool: ) -> bool:
post_text = text.strip() post_text = text.strip()
if not post_text and not image_path and not video_path: if not post_text and not image_path and not video_path:
logging.error("❌ Empty post text with no media is not allowed.") logging.error("❌ Empty post text with no media is not allowed.")
return False 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: 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: uri = getattr(resp, "uri", None) or (resp.get("uri") if isinstance(resp, dict) else None)
logging.info(f"🎬 Preparing video upload: {video_path}") logging.info(f" Post published! URI: {uri}")
embed_obj = upload_video_smart( return True
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
# ============================================================
# CLI
# ============================================================
def main(): def main():
setup_logging() setup_logging()
parser = argparse.ArgumentParser(description="Post text + optional image/video to Bluesky/federated PDS.") parser = argparse.ArgumentParser(description="Post text + optional image/video to Bluesky/federated PDS.")
parser.add_argument("text", help="Post text content") parser.add_argument("text", help="Post text content")
parser.add_argument("--username", required=True, help="Bluesky handle/email") parser.add_argument("--username", required=True)
parser.add_argument("--password", required=True, help="Bluesky app password") parser.add_argument("--password", required=True)
parser.add_argument("--service", default="https://bsky.social", help="PDS URL") parser.add_argument("--service", default="https://bsky.social")
parser.add_argument("--lang", default="ca", help="Comma-separated language codes") parser.add_argument("--lang", default="ca")
parser.add_argument("--image", default=None, help="Path to image file") parser.add_argument("--image", default=None)
parser.add_argument("--video", default=None, help="Path to video file") parser.add_argument("--video", default=None)
parser.add_argument("--alt", default="", help="Alt text for media") parser.add_argument("--alt", default="")
parser.add_argument("--video-settle-delay", type=float, default=30.0) parser.add_argument("--video-settle-delay", type=float, default=30.0)
parser.add_argument("--allow-pds-video-fallback", action="store_true") 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("--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("--no-compress-video", dest="compress_video", action="store_false")
parser.add_argument("--max-video-mb", type=float, default=45.0) parser.add_argument("--max-video-mb", type=float, default=45.0)
@@ -698,24 +492,19 @@ def main():
sys.exit(1) sys.exit(1)
client = Client(base_url=args.service) client = Client(base_url=args.service)
if not login_with_backoff( if not login_with_backoff(
client=client, client, args.username, args.password, args.service,
username=args.username, RetryConfig.login_max_attempts,
password=args.password, RetryConfig.login_base_delay_seconds,
service_url=args.service, RetryConfig.login_max_delay_seconds,
max_attempts=RetryConfig.login_max_attempts, RetryConfig.login_jitter_seconds,
base_delay=RetryConfig.login_base_delay_seconds,
max_delay=RetryConfig.login_max_delay_seconds,
jitter=RetryConfig.login_jitter_seconds,
): ):
sys.exit(1) 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 video_path_for_upload = args.video
temp_compressed_path = None temp_compressed_path = None
if args.video and args.compress_video: if args.video and args.compress_video:
compressed = compress_video_ffmpeg( compressed = compress_video_ffmpeg(
input_path=args.video, input_path=args.video,