From 1e49bd2c0975046235517b6e68a1421bedb000c7 Mon Sep 17 00:00:00 2001 From: Guillem Hernandez Sola Date: Fri, 8 May 2026 14:53:31 +0200 Subject: [PATCH] fixes --- bsky_post.py | 105 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 66 insertions(+), 39 deletions(-) diff --git a/bsky_post.py b/bsky_post.py index b128092..680fe93 100644 --- a/bsky_post.py +++ b/bsky_post.py @@ -14,6 +14,8 @@ import mimetypes import os import random import re +import secrets +import string import sys import time from dataclasses import dataclass @@ -236,6 +238,11 @@ def pds_did_from_service_url(service_url: str) -> str: return f"did:web:{host}" +def random_video_name(ext: str = ".mp4") -> str: + token = "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(12)) + return f"{int(time.time())}_{token}{ext}" + + def model_to_dict(obj): if obj is None: return None @@ -323,6 +330,42 @@ def _extract_service_auth_token(upload_auth) -> str | None: return None +def _poll_video_job(video_host: str, job_id: str) -> models.AppBskyEmbedVideo.Main | None: + status_url = f"{video_host}/xrpc/app.bsky.video.getJobStatus" + deadline = time.time() + 600 # up to 10 minutes + + 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}") + return None + + status_json = status_resp.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}") + return None + + wait_with_heartbeat(8, label="CDN propagation") + blob_ref = models.BlobRef.from_dict(blob_dict) + logging.info("✅ Video processed successfully.") + return models.AppBskyEmbedVideo.Main(video=blob_ref, alt="") + + if state == "JOB_STATE_FAILED": + logging.error(f"❌ Video processing failed: {job_status}") + return None + + logging.info(f" ...still processing (state={state})...") + time.sleep(3) + + logging.error("❌ Video processing timed out.") + return None + + def upload_video_via_bsky_service( client: Client, video_path: str, @@ -335,6 +378,7 @@ def upload_video_via_bsky_service( Critical compatibility fixes: - aud must be user's PDS DID (e.g. did:web:eurosky.social) - lxm must be com.atproto.repo.uploadBlob + - handle 409 already_exists by reusing returned jobId """ try: if not os.path.exists(video_path): @@ -350,11 +394,10 @@ def upload_video_via_bsky_service( VIDEO_HOST = "https://video.bsky.app" pds_did = pds_did_from_service_url(service_url) - # Some atproto versions prefer typed params, others accept dict try: params = models.ComAtprotoServerGetServiceAuth.Params( aud=pds_did, - lxm="com.atproto.repo.uploadBlob", # <-- critical fix + lxm="com.atproto.repo.uploadBlob", exp=int(time.time()) + 60 * 30, ) upload_auth = client.com.atproto.server.get_service_auth(params) @@ -362,7 +405,7 @@ def upload_video_via_bsky_service( upload_auth = client.com.atproto.server.get_service_auth( { "aud": pds_did, - "lxm": "com.atproto.repo.uploadBlob", # <-- critical fix + "lxm": "com.atproto.repo.uploadBlob", "exp": int(time.time()) + 60 * 30, } ) @@ -373,9 +416,12 @@ def upload_video_via_bsky_service( return None user_did = client.me.did + 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={user_did}&name={int(time.time())}.mp4" + f"?did={user_did}&name={upload_name}" ) headers = { "Authorization": f"Bearer {token}", @@ -383,50 +429,32 @@ def upload_video_via_bsky_service( } upload_resp = requests.post(upload_url, headers=headers, data=video_bytes, timeout=180) - if upload_resp.status_code != 200: + + 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"❌ video.bsky.app returned 409 without reusable jobId: {body}") + return None + job_id = body.get("jobId") if not job_id: logging.error(f"❌ No jobId returned from video service. 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 + embed = _poll_video_job(VIDEO_HOST, job_id) + if not embed: + return None - 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}") - return None - - status_json = status_resp.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}") - return None - - wait_with_heartbeat(8, label="CDN propagation") - blob_ref = models.BlobRef.from_dict(blob_dict) - logging.info("✅ Video processed successfully.") - return models.AppBskyEmbedVideo.Main(video=blob_ref, alt=alt_text) - - if state == "JOB_STATE_FAILED": - logging.error(f"❌ Video processing failed: {job_status}") - return None - - logging.info(f" ...still processing (state={state})...") - time.sleep(3) - - logging.error("❌ Video processing timed out.") - return None + # inject alt text after job result + return models.AppBskyEmbedVideo.Main(video=embed.video, alt=alt_text) except Exception as e: logging.error(f"❌ video.bsky.app upload failed: {repr(e)}") @@ -513,7 +541,7 @@ def post_to_bsky( record = { "$type": "app.bsky.feed.post", - "text": post_text, # guaranteed plain string + "text": post_text, "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), } @@ -525,7 +553,6 @@ def post_to_bsky( logging.info(f"🧾 Final record text={record.get('text')!r}, has_embed={'embed' in record}") - # typed first, dict fallback for compatibility try: resp = client.com.atproto.repo.create_record( models.ComAtprotoRepoCreateRecord.Data(