fet
This commit is contained in:
177
bsky_post.py
177
bsky_post.py
@@ -1,27 +1,27 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
bsky_post.py — Post text + optional image or video to Bluesky.
|
bsky_post.py — Post text + optional image or video to Bluesky/federated PDS.
|
||||||
|
|
||||||
Usage examples:
|
Usage examples:
|
||||||
python3 bsky_post.py "DIVENDRES!!!!" --video friday.mp4 --username you --password app-pass
|
python3 bsky_post.py "DIVENDRES!!!!" --video friday.mp4 --username you --password app-pass --service https://eurosky.social
|
||||||
python3 bsky_post.py "Dijous!!!!" --image thursday.jpg --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!" --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 "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
|
python3 bsky_post.py "Long video!" --video clip.mp4 --username you --password app-pass --allow-pds-video-fallback
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import requests
|
import sys
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
from atproto import Client, client_utils, models
|
from atproto import Client, client_utils, models
|
||||||
|
|
||||||
|
|
||||||
@@ -271,24 +271,19 @@ def login_with_backoff(
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# Utility
|
# Utility
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def is_official_bsky_pds(service_url: str) -> bool:
|
|
||||||
try:
|
|
||||||
host = (urlparse(service_url).hostname or "").lower()
|
|
||||||
return host in {"bsky.social", "bsky.app"} or host.endswith(".bsky.network")
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def detect_mime_type(path: str) -> str:
|
def detect_mime_type(path: str) -> str:
|
||||||
mime, _ = mimetypes.guess_type(path)
|
mime, _ = mimetypes.guess_type(path)
|
||||||
if mime:
|
if mime:
|
||||||
return mime
|
return mime
|
||||||
ext = os.path.splitext(path)[1].lower()
|
ext = os.path.splitext(path)[1].lower()
|
||||||
fallbacks = {
|
fallbacks = {
|
||||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
".png": "image/png", ".gif": "image/gif",
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
".mp4": "video/mp4", ".mov": "video/quicktime",
|
".mp4": "video/mp4",
|
||||||
|
".mov": "video/quicktime",
|
||||||
".webm": "video/webm",
|
".webm": "video/webm",
|
||||||
}
|
}
|
||||||
return fallbacks.get(ext, "application/octet-stream")
|
return fallbacks.get(ext, "application/octet-stream")
|
||||||
@@ -306,10 +301,16 @@ def wait_with_heartbeat(total_seconds: float, label: str = "indexing") -> None:
|
|||||||
remaining -= step
|
remaining -= step
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
logging.info(f" ...still waiting ({remaining:.0f}s remaining)...")
|
logging.info(f" ...still waiting ({remaining:.0f}s remaining)...")
|
||||||
|
|
||||||
logging.info("✅ Wait complete.")
|
logging.info("✅ Wait complete.")
|
||||||
|
|
||||||
|
|
||||||
|
def pds_did_from_service_url(service_url: str) -> str:
|
||||||
|
host = (urlparse(service_url).hostname or "").lower()
|
||||||
|
if not host:
|
||||||
|
raise ValueError(f"Invalid --service URL: {service_url}")
|
||||||
|
return f"did:web:{host}"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Media upload — Image
|
# Media upload — Image
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -327,10 +328,7 @@ def upload_image(
|
|||||||
response = client.upload_blob(data)
|
response = client.upload_blob(data)
|
||||||
logging.info("✅ Image uploaded successfully.")
|
logging.info("✅ Image uploaded successfully.")
|
||||||
|
|
||||||
return models.AppBskyEmbedImages.Image(
|
return models.AppBskyEmbedImages.Image(image=response.blob, alt=alt_text)
|
||||||
image=response.blob,
|
|
||||||
alt=alt_text,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"❌ Failed to upload image: {repr(e)}")
|
logging.error(f"❌ Failed to upload image: {repr(e)}")
|
||||||
@@ -338,7 +336,7 @@ def upload_image(
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Media upload — Video (PDS-direct fallback only)
|
# Media upload — Video (PDS direct fallback only)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def upload_video_via_pds(
|
def upload_video_via_pds(
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -347,9 +345,8 @@ def upload_video_via_pds(
|
|||||||
settle_delay_seconds: float = 30.0,
|
settle_delay_seconds: float = 30.0,
|
||||||
) -> models.AppBskyEmbedVideo.Main | None:
|
) -> models.AppBskyEmbedVideo.Main | None:
|
||||||
"""
|
"""
|
||||||
Direct upload to home PDS using upload_blob.
|
Direct upload to home PDS via upload_blob.
|
||||||
This can produce posts where blob exists but AppView playback is unreliable.
|
Kept only as optional fallback; playback can be unreliable in clients.
|
||||||
Use only as explicit fallback.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not os.path.exists(video_path):
|
if not os.path.exists(video_path):
|
||||||
@@ -360,20 +357,15 @@ def upload_video_via_pds(
|
|||||||
video_bytes = f.read()
|
video_bytes = f.read()
|
||||||
|
|
||||||
size_mb = len(video_bytes) / (1024 * 1024)
|
size_mb = len(video_bytes) / (1024 * 1024)
|
||||||
logging.warning(
|
logging.warning(f"🎬 [PDS-direct fallback] Uploading: {video_path} ({size_mb:.2f} MB)")
|
||||||
f"🎬 [PDS-direct fallback] Uploading to home PDS: {video_path} ({size_mb:.2f} MB)"
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.upload_blob(video_bytes)
|
response = client.upload_blob(video_bytes)
|
||||||
blob = response.blob
|
blob = response.blob
|
||||||
logging.warning("⚠️ [PDS-direct fallback] Video blob uploaded.")
|
logging.warning("⚠️ [PDS-direct fallback] Blob uploaded. Waiting for indexing...")
|
||||||
|
|
||||||
wait_with_heartbeat(settle_delay_seconds, label="PDS/AppView indexing the video blob")
|
wait_with_heartbeat(settle_delay_seconds, label="PDS/AppView indexing")
|
||||||
|
|
||||||
return models.AppBskyEmbedVideo.Main(
|
return models.AppBskyEmbedVideo.Main(video=blob, alt=alt_text)
|
||||||
video=blob,
|
|
||||||
alt=alt_text,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"❌ PDS-direct video upload failed: {repr(e)}")
|
logging.error(f"❌ PDS-direct video upload failed: {repr(e)}")
|
||||||
@@ -381,7 +373,7 @@ def upload_video_via_pds(
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Media upload — Video (video.bsky.app primary path)
|
# Media upload — Video (video.bsky.app primary)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def _extract_service_auth_token(upload_auth) -> str | None:
|
def _extract_service_auth_token(upload_auth) -> str | None:
|
||||||
token = getattr(upload_auth, "token", None)
|
token = getattr(upload_auth, "token", None)
|
||||||
@@ -395,11 +387,15 @@ def _extract_service_auth_token(upload_auth) -> str | None:
|
|||||||
def upload_video_via_bsky_service(
|
def upload_video_via_bsky_service(
|
||||||
client: Client,
|
client: Client,
|
||||||
video_path: str,
|
video_path: str,
|
||||||
|
service_url: str,
|
||||||
alt_text: str = "",
|
alt_text: str = "",
|
||||||
) -> models.AppBskyEmbedVideo.Main | None:
|
) -> models.AppBskyEmbedVideo.Main | None:
|
||||||
"""
|
"""
|
||||||
Upload a video via centralized video.bsky.app service.
|
Upload via centralized video.bsky.app service.
|
||||||
This is the reliable playback path for Bluesky clients.
|
|
||||||
|
IMPORTANT FIX:
|
||||||
|
getServiceAuth(aud=...) must use the user's PDS DID (e.g. did:web:eurosky.social),
|
||||||
|
not did:web:video.bsky.app.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not os.path.exists(video_path):
|
if not os.path.exists(video_path):
|
||||||
@@ -413,26 +409,28 @@ def upload_video_via_bsky_service(
|
|||||||
logging.info(f"🎬 [video.bsky.app] Uploading: {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_HOST = "https://video.bsky.app"
|
||||||
VIDEO_DID = "did:web:video.bsky.app"
|
pds_did = pds_did_from_service_url(service_url)
|
||||||
|
|
||||||
# Robust params typing across atproto versions
|
# Robust for different atproto versions
|
||||||
try:
|
try:
|
||||||
params = models.ComAtprotoServerGetServiceAuth.Params(
|
params = models.ComAtprotoServerGetServiceAuth.Params(
|
||||||
aud=VIDEO_DID,
|
aud=pds_did, # <-- critical fix
|
||||||
lxm="app.bsky.video.uploadVideo",
|
lxm="app.bsky.video.uploadVideo",
|
||||||
exp=int(time.time()) + 60 * 30,
|
exp=int(time.time()) + 60 * 30,
|
||||||
)
|
)
|
||||||
upload_auth = client.com.atproto.server.get_service_auth(params)
|
upload_auth = client.com.atproto.server.get_service_auth(params)
|
||||||
except Exception:
|
except Exception:
|
||||||
upload_auth = client.com.atproto.server.get_service_auth({
|
upload_auth = client.com.atproto.server.get_service_auth(
|
||||||
"aud": VIDEO_DID,
|
{
|
||||||
|
"aud": pds_did, # <-- critical fix
|
||||||
"lxm": "app.bsky.video.uploadVideo",
|
"lxm": "app.bsky.video.uploadVideo",
|
||||||
"exp": int(time.time()) + 60 * 30,
|
"exp": int(time.time()) + 60 * 30,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
token = _extract_service_auth_token(upload_auth)
|
token = _extract_service_auth_token(upload_auth)
|
||||||
if not token:
|
if not token:
|
||||||
logging.error("❌ Failed to get service auth token for video.bsky.app.")
|
logging.error("❌ Failed to extract service auth token.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user_did = client.me.did
|
user_did = client.me.did
|
||||||
@@ -447,30 +445,23 @@ def upload_video_via_bsky_service(
|
|||||||
|
|
||||||
upload_resp = requests.post(upload_url, headers=headers, data=video_bytes, timeout=180)
|
upload_resp = requests.post(upload_url, headers=headers, data=video_bytes, timeout=180)
|
||||||
if upload_resp.status_code != 200:
|
if upload_resp.status_code != 200:
|
||||||
logging.error(
|
logging.error(f"❌ video.bsky.app upload failed: {upload_resp.status_code} - {upload_resp.text}")
|
||||||
f"❌ video.bsky.app upload failed: "
|
|
||||||
f"{upload_resp.status_code} - {upload_resp.text}"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
payload = upload_resp.json()
|
body = upload_resp.json()
|
||||||
job_id = payload.get("jobId")
|
job_id = body.get("jobId")
|
||||||
if not job_id:
|
if not job_id:
|
||||||
logging.error(f"❌ No jobId returned from video service. Response: {payload}")
|
logging.error(f"❌ No jobId returned from video service. Response: {body}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logging.info(f"⏳ Job {job_id} accepted — polling status...")
|
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 # 10 min for big videos
|
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)
|
||||||
if status_resp.status_code != 200:
|
if status_resp.status_code != 200:
|
||||||
logging.error(
|
logging.error(f"❌ Job status check failed: {status_resp.status_code} - {status_resp.text}")
|
||||||
f"❌ Job status check failed: "
|
|
||||||
f"{status_resp.status_code} - {status_resp.text}"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
status_json = status_resp.json()
|
status_json = status_resp.json()
|
||||||
@@ -483,15 +474,10 @@ def upload_video_via_bsky_service(
|
|||||||
logging.error(f"❌ No blob in completed job status: {status_json}")
|
logging.error(f"❌ No blob in completed job status: {status_json}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Small propagation cushion
|
|
||||||
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 and blob returned.")
|
return models.AppBskyEmbedVideo.Main(video=blob_ref, alt=alt_text)
|
||||||
return models.AppBskyEmbedVideo.Main(
|
|
||||||
video=blob_ref,
|
|
||||||
alt=alt_text,
|
|
||||||
)
|
|
||||||
|
|
||||||
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}")
|
||||||
@@ -509,7 +495,7 @@ def upload_video_via_bsky_service(
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Media upload — Video dispatcher
|
# Video dispatcher
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def upload_video_smart(
|
def upload_video_smart(
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -519,38 +505,34 @@ def upload_video_smart(
|
|||||||
settle_delay_seconds: float = 30.0,
|
settle_delay_seconds: float = 30.0,
|
||||||
allow_pds_video_fallback: bool = False,
|
allow_pds_video_fallback: bool = False,
|
||||||
) -> models.AppBskyEmbedVideo.Main | None:
|
) -> models.AppBskyEmbedVideo.Main | None:
|
||||||
"""
|
|
||||||
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"🌐 PDS appears official ({service_url}). Using video.bsky.app.")
|
|
||||||
else:
|
|
||||||
logging.info(
|
logging.info(
|
||||||
f"🌍 PDS is self-hosted/federated ({service_url}). "
|
f"🌍 PDS ({service_url}). Trying video.bsky.app first for playback reliability."
|
||||||
"Still trying video.bsky.app first for client playback reliability."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
embed = upload_video_via_bsky_service(client, video_path, alt_text=alt_text)
|
embed = upload_video_via_bsky_service(
|
||||||
|
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(
|
logging.warning(
|
||||||
"⚠️ video.bsky.app upload failed; trying direct PDS fallback "
|
"⚠️ video.bsky.app failed; trying direct PDS fallback "
|
||||||
"(may produce unplayable videos in some clients)."
|
"(may be unplayable in some clients)."
|
||||||
)
|
)
|
||||||
return upload_video_via_pds(
|
return upload_video_via_pds(
|
||||||
client,
|
client=client,
|
||||||
video_path,
|
video_path=video_path,
|
||||||
alt_text=alt_text,
|
alt_text=alt_text,
|
||||||
settle_delay_seconds=settle_delay_seconds,
|
settle_delay_seconds=settle_delay_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
logging.error(
|
logging.error(
|
||||||
"❌ video.bsky.app upload failed. Not posting unreliable direct-PDS video "
|
"❌ video.bsky.app failed. Not posting unreliable direct-PDS video. "
|
||||||
"(enable --allow-pds-video-fallback to override)."
|
"Use --allow-pds-video-fallback to override."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -576,8 +558,8 @@ def post_to_bsky(
|
|||||||
logging.info(f"🎬 Preparing video upload: {video_path}")
|
logging.info(f"🎬 Preparing video upload: {video_path}")
|
||||||
|
|
||||||
video_embed = upload_video_smart(
|
video_embed = upload_video_smart(
|
||||||
client,
|
client=client,
|
||||||
video_path,
|
video_path=video_path,
|
||||||
service_url=service_url,
|
service_url=service_url,
|
||||||
alt_text=alt_text,
|
alt_text=alt_text,
|
||||||
settle_delay_seconds=video_settle_delay,
|
settle_delay_seconds=video_settle_delay,
|
||||||
@@ -589,11 +571,7 @@ def post_to_bsky(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
logging.info("🚀 Sending video post...")
|
logging.info("🚀 Sending video post...")
|
||||||
result = client.send_post(
|
result = client.send_post(text=rich_text, embed=video_embed, langs=langs)
|
||||||
text=rich_text,
|
|
||||||
embed=video_embed,
|
|
||||||
langs=langs,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif image_path:
|
elif image_path:
|
||||||
image = upload_image(client, image_path, alt_text=alt_text)
|
image = upload_image(client, image_path, alt_text=alt_text)
|
||||||
@@ -625,7 +603,7 @@ def main():
|
|||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Post text + optional image or video to Bluesky."
|
description="Post text + optional image or 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 or email")
|
parser.add_argument("--username", required=True, help="Bluesky handle or email")
|
||||||
@@ -639,12 +617,12 @@ def main():
|
|||||||
"--video-settle-delay",
|
"--video-settle-delay",
|
||||||
type=float,
|
type=float,
|
||||||
default=30.0,
|
default=30.0,
|
||||||
help="Seconds to wait after direct-PDS video fallback upload before posting (default: 30)",
|
help="Seconds to wait after direct-PDS fallback upload before posting (default: 30).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--allow-pds-video-fallback",
|
"--allow-pds-video-fallback",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Allow direct-PDS fallback if video.bsky.app upload fails (less reliable playback).",
|
help="Allow direct-PDS fallback if video.bsky.app fails (less reliable playback).",
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -655,10 +633,10 @@ def main():
|
|||||||
|
|
||||||
client = Client(base_url=args.service)
|
client = Client(base_url=args.service)
|
||||||
success = login_with_backoff(
|
success = login_with_backoff(
|
||||||
client,
|
client=client,
|
||||||
args.username,
|
username=args.username,
|
||||||
args.password,
|
password=args.password,
|
||||||
args.service,
|
service_url=args.service,
|
||||||
max_attempts=RetryConfig.login_max_attempts,
|
max_attempts=RetryConfig.login_max_attempts,
|
||||||
base_delay=RetryConfig.login_base_delay_seconds,
|
base_delay=RetryConfig.login_base_delay_seconds,
|
||||||
max_delay=RetryConfig.login_max_delay_seconds,
|
max_delay=RetryConfig.login_max_delay_seconds,
|
||||||
@@ -668,8 +646,9 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
langs = [l.strip() for l in args.lang.split(",") if l.strip()]
|
langs = [l.strip() for l in args.lang.split(",") if l.strip()]
|
||||||
|
|
||||||
post_success = post_to_bsky(
|
post_success = post_to_bsky(
|
||||||
client,
|
client=client,
|
||||||
text=args.text,
|
text=args.text,
|
||||||
langs=langs,
|
langs=langs,
|
||||||
image_path=args.image,
|
image_path=args.image,
|
||||||
|
|||||||
Reference in New Issue
Block a user