Opus Fixes
This commit is contained in:
94
bsky_post.py
94
bsky_post.py
@@ -17,11 +17,10 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urlparse
|
||||||
from atproto import Client, client_utils, models
|
from atproto import Client, client_utils, models
|
||||||
|
|
||||||
|
|
||||||
@@ -151,7 +150,6 @@ def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: floa
|
|||||||
try:
|
try:
|
||||||
now_ts = int(time.time())
|
now_ts = int(time.time())
|
||||||
|
|
||||||
# Direct headers on exception
|
|
||||||
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:
|
||||||
@@ -169,7 +167,6 @@ def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: floa
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Nested response headers
|
|
||||||
response = getattr(error_obj, "response", None)
|
response = getattr(error_obj, "response", None)
|
||||||
headers = getattr(response, "headers", None) or {}
|
headers = getattr(response, "headers", None) or {}
|
||||||
now_ts = int(time.time())
|
now_ts = int(time.time())
|
||||||
@@ -189,7 +186,6 @@ def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: floa
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# repr fallback parsing
|
|
||||||
text = repr(error_obj)
|
text = repr(error_obj)
|
||||||
m = re.search(r"'retry-after': '(\d+)'", text, re.IGNORECASE)
|
m = re.search(r"'retry-after': '(\d+)'", text, re.IGNORECASE)
|
||||||
if m:
|
if m:
|
||||||
@@ -233,12 +229,10 @@ def login_with_backoff(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception("❌ Login exception")
|
logging.exception("❌ Login exception")
|
||||||
|
|
||||||
# Fail fast on invalid credentials
|
|
||||||
if is_auth_error(e):
|
if is_auth_error(e):
|
||||||
logging.error("❌ Bad credentials. Check handle/password.")
|
logging.error("❌ Bad credentials. Check handle/password.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Respect explicit rate-limit timing
|
|
||||||
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, default_delay=base_delay, max_delay=max_delay)
|
||||||
@@ -253,7 +247,6 @@ def login_with_backoff(
|
|||||||
logging.error("❌ Exhausted login retries due to rate limiting.")
|
logging.error("❌ Exhausted login retries due to rate limiting.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Retry transient/network problems
|
|
||||||
if is_network_error(e) or is_transient_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)
|
||||||
@@ -267,7 +260,6 @@ def login_with_backoff(
|
|||||||
logging.error("❌ Exhausted login retries after transient/network errors.")
|
logging.error("❌ Exhausted login retries after transient/network errors.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Unknown errors: bounded retry anyway
|
|
||||||
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(
|
logging.warning(
|
||||||
@@ -281,7 +273,30 @@ def login_with_backoff(
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Media upload
|
# URL / DID helpers
|
||||||
|
# ============================================================
|
||||||
|
def normalize_pds_base(service_url: str) -> str:
|
||||||
|
"""
|
||||||
|
Strip trailing slashes and any trailing /xrpc segment so callers can safely
|
||||||
|
compose '<base>/xrpc/<method>' URLs.
|
||||||
|
"""
|
||||||
|
base = service_url.rstrip("/")
|
||||||
|
if base.endswith("/xrpc"):
|
||||||
|
base = base[: -len("/xrpc")]
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def pds_did_from_base(pds_base: str) -> str:
|
||||||
|
"""
|
||||||
|
Derive the did:web for a PDS from its base URL hostname.
|
||||||
|
Works for typical atproto PDS deployments (e.g. https://eurosky.social → did:web:eurosky.social).
|
||||||
|
"""
|
||||||
|
host = urlparse(pds_base).netloc
|
||||||
|
return f"did:web:{host}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Media upload — Image
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def detect_mime_type(path: str) -> str:
|
def detect_mime_type(path: str) -> str:
|
||||||
mime, _ = mimetypes.guess_type(path)
|
mime, _ = mimetypes.guess_type(path)
|
||||||
@@ -321,38 +336,46 @@ def upload_image(
|
|||||||
logging.error(f"❌ Failed to upload image: {repr(e)}")
|
logging.error(f"❌ Failed to upload image: {repr(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Media upload — Video
|
||||||
|
# ============================================================
|
||||||
def upload_video_and_wait(
|
def upload_video_and_wait(
|
||||||
client: Client,
|
client: Client,
|
||||||
video_data: bytes,
|
video_data: bytes,
|
||||||
alt_text: str = ""
|
alt_text: str = "",
|
||||||
|
service_url: str = "https://bsky.social",
|
||||||
) -> models.AppBskyEmbedVideo.Main | None:
|
) -> models.AppBskyEmbedVideo.Main | None:
|
||||||
try:
|
"""
|
||||||
# --- Resolve PDS host + DID dynamically ---
|
Upload a video to the user's PDS video service and wait for processing.
|
||||||
# The Client stores the service URL it logged into
|
|
||||||
pds_base = str(client._base_url).rstrip("/") # e.g. https://eurosky.social
|
|
||||||
|
|
||||||
# Derive the DID from the hostname (works for did:web PDSes)
|
Notes on portability:
|
||||||
from urllib.parse import urlparse
|
* Self-hosted/federated PDSes (e.g. eurosky.social) typically host the
|
||||||
host = urlparse(pds_base).netloc
|
video XRPC endpoints on the PDS itself, with `aud = did:web:<pds-host>`.
|
||||||
service_did = f"did:web:{host}"
|
* The official Bluesky network uses a separate host (video.bsky.app),
|
||||||
|
but this implementation targets the PDS-hosted variant which works
|
||||||
|
for both eurosky-style PDSes and any conforming atproto deployment.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pds_base = normalize_pds_base(service_url)
|
||||||
|
service_did = pds_did_from_base(pds_base)
|
||||||
|
|
||||||
logging.info(f"🎬 Using video service at {pds_base} (aud={service_did})")
|
logging.info(f"🎬 Using video service at {pds_base} (aud={service_did})")
|
||||||
|
|
||||||
# --- Token #1: uploadVideo, scoped to *this* PDS ---
|
# --- Token #1: bound to uploadVideo ---
|
||||||
logging.info("🎬 Requesting Service Auth for Video Upload...")
|
logging.info("🎬 Requesting Service Auth for Video Upload...")
|
||||||
upload_auth = client.com.atproto.server.get_service_auth({
|
upload_auth = client.com.atproto.server.get_service_auth({
|
||||||
'aud': service_did,
|
'aud': service_did,
|
||||||
'lxm': 'app.bsky.video.uploadVideo',
|
'lxm': 'app.bsky.video.uploadVideo',
|
||||||
'exp': int(time.time()) + 60 * 30,
|
'exp': int(time.time()) + 60 * 30, # 30 min (allowed because lxm is set)
|
||||||
})
|
})
|
||||||
upload_headers = {
|
upload_headers = {
|
||||||
"Authorization": f"Bearer {upload_auth.token}",
|
"Authorization": f"Bearer {upload_auth.token}",
|
||||||
"Content-Type": "video/mp4",
|
"Content-Type": "video/mp4",
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Upload to the PDS, not video.bsky.app ---
|
|
||||||
upload_url = f"{pds_base}/xrpc/app.bsky.video.uploadVideo"
|
upload_url = f"{pds_base}/xrpc/app.bsky.video.uploadVideo"
|
||||||
logging.info(f"🎬 Uploading video to {upload_url}...")
|
logging.info(f"🎬 Uploading video to {upload_url} ...")
|
||||||
upload_resp = requests.post(upload_url, headers=upload_headers, data=video_data)
|
upload_resp = requests.post(upload_url, headers=upload_headers, data=video_data)
|
||||||
|
|
||||||
if upload_resp.status_code != 200:
|
if upload_resp.status_code != 200:
|
||||||
@@ -366,7 +389,7 @@ def upload_video_and_wait(
|
|||||||
|
|
||||||
logging.info(f"⏳ Video uploaded! Job ID: {job_id}. Waiting for processing...")
|
logging.info(f"⏳ Video uploaded! Job ID: {job_id}. Waiting for processing...")
|
||||||
|
|
||||||
# --- Token #2: getJobStatus ---
|
# --- Token #2: bound to getJobStatus ---
|
||||||
status_auth = client.com.atproto.server.get_service_auth({
|
status_auth = client.com.atproto.server.get_service_auth({
|
||||||
'aud': service_did,
|
'aud': service_did,
|
||||||
'lxm': 'app.bsky.video.getJobStatus',
|
'lxm': 'app.bsky.video.getJobStatus',
|
||||||
@@ -396,7 +419,7 @@ def upload_video_and_wait(
|
|||||||
|
|
||||||
return models.AppBskyEmbedVideo.Main(
|
return models.AppBskyEmbedVideo.Main(
|
||||||
video=blob_ref,
|
video=blob_ref,
|
||||||
alt=alt_text
|
alt=alt_text,
|
||||||
)
|
)
|
||||||
elif state == 'JOB_STATE_FAILED':
|
elif state == 'JOB_STATE_FAILED':
|
||||||
logging.error("❌ Video processing failed on Bluesky's servers.")
|
logging.error("❌ Video processing failed on Bluesky's servers.")
|
||||||
@@ -409,6 +432,7 @@ def upload_video_and_wait(
|
|||||||
logging.error(f"❌ Failed to upload/process video: {repr(e)}")
|
logging.error(f"❌ Failed to upload/process video: {repr(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Post
|
# Post
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -420,6 +444,7 @@ def post_to_bsky(
|
|||||||
video_path: str | None = None,
|
video_path: str | None = None,
|
||||||
alt_text: str = "",
|
alt_text: str = "",
|
||||||
password: str = "",
|
password: str = "",
|
||||||
|
service_url: str = "https://bsky.social",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
rich_text = make_rich(text)
|
rich_text = make_rich(text)
|
||||||
|
|
||||||
@@ -430,20 +455,22 @@ def post_to_bsky(
|
|||||||
with open(video_path, "rb") as f:
|
with open(video_path, "rb") as f:
|
||||||
video_data = f.read()
|
video_data = f.read()
|
||||||
|
|
||||||
# Pass the password to our custom polling function
|
video_embed = upload_video_and_wait(
|
||||||
# Use our custom polling function (no password needed)
|
client,
|
||||||
video_embed = upload_video_and_wait(client, video_data, alt_text)
|
video_data,
|
||||||
|
alt_text=alt_text,
|
||||||
|
service_url=service_url,
|
||||||
|
)
|
||||||
|
|
||||||
if not video_embed:
|
if not video_embed:
|
||||||
logging.error("❌ Aborting post: video upload/processing failed.")
|
logging.error("❌ Aborting post: video upload/processing failed.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logging.info(f"🚀 Sending video post...")
|
logging.info("🚀 Sending video post...")
|
||||||
result = client.send_post(
|
result = client.send_post(
|
||||||
text=rich_text,
|
text=rich_text,
|
||||||
embed=video_embed,
|
embed=video_embed,
|
||||||
langs=langs
|
langs=langs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- IMAGE POSTING ---
|
# --- IMAGE POSTING ---
|
||||||
@@ -454,12 +481,12 @@ def post_to_bsky(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
embed = models.AppBskyEmbedImages.Main(images=[image])
|
embed = models.AppBskyEmbedImages.Main(images=[image])
|
||||||
logging.info(f"🚀 Sending image post...")
|
logging.info("🚀 Sending image post...")
|
||||||
result = client.send_post(text=rich_text, embed=embed, langs=langs)
|
result = client.send_post(text=rich_text, embed=embed, langs=langs)
|
||||||
|
|
||||||
# --- TEXT ONLY POSTING ---
|
# --- TEXT ONLY POSTING ---
|
||||||
else:
|
else:
|
||||||
logging.info(f"🚀 Sending text post...")
|
logging.info("🚀 Sending text post...")
|
||||||
result = client.send_post(text=rich_text, langs=langs)
|
result = client.send_post(text=rich_text, langs=langs)
|
||||||
|
|
||||||
uri = getattr(result, "uri", None)
|
uri = getattr(result, "uri", None)
|
||||||
@@ -514,6 +541,7 @@ def main():
|
|||||||
video_path=args.video,
|
video_path=args.video,
|
||||||
alt_text=args.alt,
|
alt_text=args.alt,
|
||||||
password=args.password,
|
password=args.password,
|
||||||
|
service_url=args.service,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not post_success:
|
if not post_success:
|
||||||
|
|||||||
Reference in New Issue
Block a user