This commit is contained in:
Guillem Hernandez Sola
2026-05-08 14:53:31 +02:00
parent 022af846a4
commit 1e49bd2c09

View File

@@ -14,6 +14,8 @@ import mimetypes
import os import os
import random import random
import re import re
import secrets
import string
import sys import sys
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@@ -236,6 +238,11 @@ def pds_did_from_service_url(service_url: str) -> str:
return f"did:web:{host}" 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): def model_to_dict(obj):
if obj is None: if obj is None:
return None return None
@@ -323,79 +330,9 @@ def _extract_service_auth_token(upload_auth) -> str | None:
return None return None
def upload_video_via_bsky_service( def _poll_video_job(video_host: str, job_id: str) -> models.AppBskyEmbedVideo.Main | None:
client: Client, status_url = f"{video_host}/xrpc/app.bsky.video.getJobStatus"
video_path: str, deadline = time.time() + 600 # up to 10 minutes
service_url: str,
alt_text: str = "",
) -> models.AppBskyEmbedVideo.Main | None:
"""
Upload via centralized video.bsky.app service.
Critical compatibility fixes:
- aud must be user's PDS DID (e.g. did:web:eurosky.social)
- lxm must be com.atproto.repo.uploadBlob
"""
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)")
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
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", # <-- critical fix
"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
user_did = client.me.did
upload_url = (
f"{VIDEO_HOST}/xrpc/app.bsky.video.uploadVideo"
f"?did={user_did}&name={int(time.time())}.mp4"
)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "video/mp4",
}
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: {upload_resp.status_code} - {upload_resp.text}")
return None
body = upload_resp.json()
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
while time.time() < deadline: while time.time() < deadline:
status_resp = requests.get(status_url, params={"jobId": job_id}, timeout=30) status_resp = requests.get(status_url, params={"jobId": job_id}, timeout=30)
@@ -416,7 +353,7 @@ def upload_video_via_bsky_service(
wait_with_heartbeat(8, label="CDN propagation") wait_with_heartbeat(8, label="CDN propagation")
blob_ref = models.BlobRef.from_dict(blob_dict) blob_ref = models.BlobRef.from_dict(blob_dict)
logging.info("✅ Video processed successfully.") logging.info("✅ Video processed successfully.")
return models.AppBskyEmbedVideo.Main(video=blob_ref, alt=alt_text) return models.AppBskyEmbedVideo.Main(video=blob_ref, alt="")
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}")
@@ -428,6 +365,97 @@ def upload_video_via_bsky_service(
logging.error("❌ Video processing timed out.") logging.error("❌ Video processing timed out.")
return None return None
def upload_video_via_bsky_service(
client: Client,
video_path: str,
service_url: str,
alt_text: str = "",
) -> models.AppBskyEmbedVideo.Main | None:
"""
Upload via centralized video.bsky.app 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):
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)
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.")
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={upload_name}"
)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "video/mp4",
}
upload_resp = requests.post(upload_url, headers=headers, data=video_bytes, timeout=180)
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...")
embed = _poll_video_job(VIDEO_HOST, job_id)
if not embed:
return None
# inject alt text after job result
return models.AppBskyEmbedVideo.Main(video=embed.video, alt=alt_text)
except Exception as e: except Exception as e:
logging.error(f"❌ video.bsky.app upload failed: {repr(e)}") logging.error(f"❌ video.bsky.app upload failed: {repr(e)}")
return None return None
@@ -513,7 +541,7 @@ def post_to_bsky(
record = { record = {
"$type": "app.bsky.feed.post", "$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()), "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}") logging.info(f"🧾 Final record text={record.get('text')!r}, has_embed={'embed' in record}")
# typed first, dict fallback for compatibility
try: try:
resp = client.com.atproto.repo.create_record( resp = client.com.atproto.repo.create_record(
models.ComAtprotoRepoCreateRecord.Data( models.ComAtprotoRepoCreateRecord.Data(