fixes
This commit is contained in:
260
bsky_post.py
260
bsky_post.py
@@ -1,13 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
bsky_post.py — Post text + optional image or video to a Bluesky instance.
|
bsky_post.py — Post text + optional image or video to Bluesky.
|
||||||
|
|
||||||
Usage examples:
|
Usage examples:
|
||||||
python3 bsky_post.py "DIVENDRES!!!!" --video friday.mp4
|
python3 bsky_post.py "DIVENDRES!!!!" --video friday.mp4 --username you --password app-pass
|
||||||
python3 bsky_post.py "Dijous!!!!" --image thursday.jpg
|
python3 bsky_post.py "Dijous!!!!" --image thursday.jpg --username you --password app-pass
|
||||||
python3 bsky_post.py "Bon dia!"
|
python3 bsky_post.py "Bon dia!" --username you --password app-pass
|
||||||
python3 bsky_post.py "Bon dia!" --image photo.jpg --lang ca
|
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 --video-settle-delay 25
|
python3 bsky_post.py "Long video!" --video clip.mp4 --username you --password app-pass
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -33,7 +33,7 @@ class RetryConfig:
|
|||||||
login_max_attempts: int = 5
|
login_max_attempts: int = 5
|
||||||
login_base_delay_seconds: float = 10.0
|
login_base_delay_seconds: float = 10.0
|
||||||
login_max_delay_seconds: float = 600.0
|
login_max_delay_seconds: float = 600.0
|
||||||
login_jitter_seconds: float = 3
|
login_jitter_seconds: float = 3.0
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -41,7 +41,7 @@ class RetryConfig:
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
def setup_logging() -> None:
|
def setup_logging() -> None:
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
format="%(asctime)s %(message)s",
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
stream=sys.stdout,
|
stream=sys.stdout,
|
||||||
)
|
)
|
||||||
@@ -217,9 +217,7 @@ def login_with_backoff(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
for attempt in range(1, max_attempts + 1):
|
for attempt in range(1, max_attempts + 1):
|
||||||
try:
|
try:
|
||||||
logging.info(
|
logging.info(f"🔑 Login attempt {attempt}/{max_attempts} → {service_url} as {username}")
|
||||||
f"🔑 Login attempt {attempt}/{max_attempts} → {service_url} as {username}"
|
|
||||||
)
|
|
||||||
client.login(username, password)
|
client.login(username, password)
|
||||||
logging.info("✅ Login successful.")
|
logging.info("✅ Login successful.")
|
||||||
return True
|
return True
|
||||||
@@ -271,26 +269,16 @@ def login_with_backoff(
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PDS detection
|
# Utility
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def is_official_bsky_pds(service_url: str) -> bool:
|
def is_official_bsky_pds(service_url: str) -> bool:
|
||||||
"""
|
|
||||||
Detect whether the configured PDS is part of the official Bluesky-operated
|
|
||||||
network. Self-hosted/federated PDSes (e.g. eurosky.social) return False.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
host = (urlparse(service_url).hostname or "").lower()
|
host = (urlparse(service_url).hostname or "").lower()
|
||||||
return (
|
return host in {"bsky.social", "bsky.app"} or host.endswith(".bsky.network")
|
||||||
host in {"bsky.social", "bsky.app"}
|
|
||||||
or host.endswith(".bsky.network")
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 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)
|
||||||
if mime:
|
if mime:
|
||||||
@@ -306,6 +294,25 @@ def detect_mime_type(path: str) -> str:
|
|||||||
return fallbacks.get(ext, "application/octet-stream")
|
return fallbacks.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_with_heartbeat(total_seconds: float, label: str = "indexing") -> None:
|
||||||
|
if total_seconds <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
logging.info(f"⏳ Waiting {total_seconds:.0f}s for {label}...")
|
||||||
|
remaining = total_seconds
|
||||||
|
while remaining > 0:
|
||||||
|
step = min(5.0, remaining)
|
||||||
|
time.sleep(step)
|
||||||
|
remaining -= step
|
||||||
|
if remaining > 0:
|
||||||
|
logging.info(f" ...still waiting ({remaining:.0f}s remaining)...")
|
||||||
|
|
||||||
|
logging.info("✅ Wait complete.")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Media upload — Image
|
||||||
|
# ============================================================
|
||||||
def upload_image(
|
def upload_image(
|
||||||
client: Client,
|
client: Client,
|
||||||
image_path: str,
|
image_path: str,
|
||||||
@@ -316,7 +323,7 @@ def upload_image(
|
|||||||
with open(image_path, "rb") as f:
|
with open(image_path, "rb") as f:
|
||||||
data = f.read()
|
data = f.read()
|
||||||
|
|
||||||
logging.info(f"🖼️ Uploading image: {image_path} ({len(data)/1024:.1f} KB, {mime})")
|
logging.info(f"🖼️ Uploading image: {image_path} ({len(data)/1024:.1f} KB, {mime})")
|
||||||
response = client.upload_blob(data)
|
response = client.upload_blob(data)
|
||||||
logging.info("✅ Image uploaded successfully.")
|
logging.info("✅ Image uploaded successfully.")
|
||||||
|
|
||||||
@@ -331,49 +338,18 @@ def upload_image(
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Helpers — settle delay
|
# Media upload — Video (PDS-direct fallback only)
|
||||||
# ============================================================
|
|
||||||
def wait_with_heartbeat(total_seconds: float, label: str = "indexing") -> None:
|
|
||||||
"""
|
|
||||||
Sleep `total_seconds` while logging a heartbeat every 5s so the operator
|
|
||||||
can see we're still alive and how much time is left.
|
|
||||||
"""
|
|
||||||
if total_seconds <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
logging.info(f"⏳ Waiting {total_seconds:.0f}s for PDS {label}...")
|
|
||||||
remaining = total_seconds
|
|
||||||
while remaining > 0:
|
|
||||||
step = min(5.0, remaining)
|
|
||||||
time.sleep(step)
|
|
||||||
remaining -= step
|
|
||||||
if remaining > 0:
|
|
||||||
logging.info(f" ...still waiting ({remaining:.0f}s remaining)...")
|
|
||||||
|
|
||||||
logging.info("✅ Settle delay complete.")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Media upload — Video (PDS-direct)
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def upload_video_via_pds(
|
def upload_video_via_pds(
|
||||||
client: Client,
|
client: Client,
|
||||||
video_path: str,
|
video_path: str,
|
||||||
alt_text: str = "",
|
alt_text: str = "",
|
||||||
settle_delay_seconds: float = 60.0,
|
settle_delay_seconds: float = 30.0,
|
||||||
) -> models.AppBskyEmbedVideo.Main | None:
|
) -> models.AppBskyEmbedVideo.Main | None:
|
||||||
"""
|
"""
|
||||||
Upload a video as a generic blob directly to the user's PDS.
|
Direct upload to home PDS using upload_blob.
|
||||||
|
This can produce posts where blob exists but AppView playback is unreliable.
|
||||||
Used for self-hosted / federated PDSes (e.g. eurosky.social) that don't
|
Use only as explicit fallback.
|
||||||
proxy to the centralized video.bsky.app service. The PDS stores the bytes
|
|
||||||
and returns a blob ref we can embed directly.
|
|
||||||
|
|
||||||
A settle delay is applied AFTER the upload returns, because some PDSes
|
|
||||||
(notably non-Bluesky-operated ones) index the blob asynchronously. If we
|
|
||||||
publish the post immediately, the AppView can render "video not found"
|
|
||||||
until indexing catches up — the delay ensures the post references a blob
|
|
||||||
that's already retrievable.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not os.path.exists(video_path):
|
if not os.path.exists(video_path):
|
||||||
@@ -384,16 +360,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.info(
|
logging.warning(
|
||||||
f"🎬 [PDS-direct] Uploading video to home PDS: {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.info("✅ [PDS-direct] Video blob uploaded successfully.")
|
logging.warning("⚠️ [PDS-direct fallback] Video blob uploaded.")
|
||||||
|
|
||||||
# Give PDS / AppView time to index the blob before we reference it.
|
wait_with_heartbeat(settle_delay_seconds, label="PDS/AppView indexing the video blob")
|
||||||
wait_with_heartbeat(settle_delay_seconds, label="to index the video blob")
|
|
||||||
|
|
||||||
return models.AppBskyEmbedVideo.Main(
|
return models.AppBskyEmbedVideo.Main(
|
||||||
video=blob,
|
video=blob,
|
||||||
@@ -406,16 +381,25 @@ def upload_video_via_pds(
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Media upload — Video (video.bsky.app shared service)
|
# Media upload — Video (video.bsky.app primary path)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
def upload_video_via_bsky_service(
|
def upload_video_via_bsky_service(
|
||||||
client: Client,
|
client: Client,
|
||||||
video_path: str,
|
video_path: str,
|
||||||
alt_text: str = "",
|
alt_text: str = "",
|
||||||
) -> models.AppBskyEmbedVideo.Main | None:
|
) -> models.AppBskyEmbedVideo.Main | None:
|
||||||
"""
|
"""
|
||||||
Upload a video via the centralized video.bsky.app service.
|
Upload a video via centralized video.bsky.app service.
|
||||||
Used only when the configured PDS is on the official Bluesky network.
|
This is the reliable playback path for Bluesky clients.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not os.path.exists(video_path):
|
if not os.path.exists(video_path):
|
||||||
@@ -426,18 +410,30 @@ def upload_video_via_bsky_service(
|
|||||||
video_bytes = f.read()
|
video_bytes = f.read()
|
||||||
|
|
||||||
size_mb = len(video_bytes) / (1024 * 1024)
|
size_mb = len(video_bytes) / (1024 * 1024)
|
||||||
logging.info(
|
logging.info(f"🎬 [video.bsky.app] Uploading: {video_path} ({size_mb:.2f} MB)")
|
||||||
f"🎬 [video.bsky.app] Uploading via shared service: {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"
|
VIDEO_DID = "did:web:video.bsky.app"
|
||||||
|
|
||||||
upload_auth = client.com.atproto.server.get_service_auth({
|
# Robust params typing across atproto versions
|
||||||
"aud": VIDEO_DID,
|
try:
|
||||||
"lxm": "app.bsky.video.uploadVideo",
|
params = models.ComAtprotoServerGetServiceAuth.Params(
|
||||||
"exp": int(time.time()) + 60 * 30,
|
aud=VIDEO_DID,
|
||||||
})
|
lxm="app.bsky.video.uploadVideo",
|
||||||
|
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": VIDEO_DID,
|
||||||
|
"lxm": "app.bsky.video.uploadVideo",
|
||||||
|
"exp": int(time.time()) + 60 * 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
token = _extract_service_auth_token(upload_auth)
|
||||||
|
if not token:
|
||||||
|
logging.error("❌ Failed to get service auth token for video.bsky.app.")
|
||||||
|
return None
|
||||||
|
|
||||||
user_did = client.me.did
|
user_did = client.me.did
|
||||||
upload_url = (
|
upload_url = (
|
||||||
@@ -445,11 +441,11 @@ def upload_video_via_bsky_service(
|
|||||||
f"?did={user_did}&name={int(time.time())}.mp4"
|
f"?did={user_did}&name={int(time.time())}.mp4"
|
||||||
)
|
)
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {upload_auth.token}",
|
"Authorization": f"Bearer {token}",
|
||||||
"Content-Type": "video/mp4",
|
"Content-Type": "video/mp4",
|
||||||
}
|
}
|
||||||
|
|
||||||
upload_resp = requests.post(upload_url, headers=headers, data=video_bytes, timeout=120)
|
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: "
|
f"❌ video.bsky.app upload failed: "
|
||||||
@@ -457,15 +453,16 @@ def upload_video_via_bsky_service(
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
job_id = upload_resp.json().get("jobId")
|
payload = upload_resp.json()
|
||||||
|
job_id = payload.get("jobId")
|
||||||
if not job_id:
|
if not job_id:
|
||||||
logging.error("❌ No jobId returned from video service.")
|
logging.error(f"❌ No jobId returned from video service. Response: {payload}")
|
||||||
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() + 300
|
deadline = time.time() + 600 # 10 min for big videos
|
||||||
|
|
||||||
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)
|
||||||
@@ -476,32 +473,34 @@ def upload_video_via_bsky_service(
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
job_status = status_resp.json().get("jobStatus", {})
|
status_json = status_resp.json()
|
||||||
|
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":
|
||||||
logging.info("✅ Processing complete! Waiting 10s for CDN propagation...")
|
|
||||||
time.sleep(10)
|
|
||||||
|
|
||||||
blob_dict = job_status.get("blob")
|
blob_dict = job_status.get("blob")
|
||||||
if not blob_dict:
|
if not blob_dict:
|
||||||
logging.error("❌ No blob in completed job status.")
|
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")
|
||||||
blob_ref = models.BlobRef.from_dict(blob_dict)
|
blob_ref = models.BlobRef.from_dict(blob_dict)
|
||||||
|
|
||||||
|
logging.info("✅ Video processed and blob returned.")
|
||||||
return models.AppBskyEmbedVideo.Main(
|
return models.AppBskyEmbedVideo.Main(
|
||||||
video=blob_ref,
|
video=blob_ref,
|
||||||
alt=alt_text,
|
alt=alt_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
if state == "JOB_STATE_FAILED":
|
if state == "JOB_STATE_FAILED":
|
||||||
logging.error(f"❌ Video processing failed on Bluesky's servers: {job_status}")
|
logging.error(f"❌ Video processing failed: {job_status}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logging.info(" ...still processing...")
|
logging.info(f" ...still processing (state={state})...")
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
logging.error("❌ Video processing timed out after 5 minutes.")
|
logging.error("❌ Video processing timed out.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -510,39 +509,50 @@ def upload_video_via_bsky_service(
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Media upload — Video (smart dispatcher)
|
# Media upload — Video dispatcher
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def upload_video_smart(
|
def upload_video_smart(
|
||||||
client: Client,
|
client: Client,
|
||||||
video_path: str,
|
video_path: str,
|
||||||
service_url: str,
|
service_url: str,
|
||||||
alt_text: str = "",
|
alt_text: str = "",
|
||||||
settle_delay_seconds: float = 15.0,
|
settle_delay_seconds: float = 30.0,
|
||||||
|
allow_pds_video_fallback: bool = False,
|
||||||
) -> models.AppBskyEmbedVideo.Main | None:
|
) -> models.AppBskyEmbedVideo.Main | None:
|
||||||
"""
|
"""
|
||||||
Smart dispatcher:
|
Reliable policy:
|
||||||
* Self-hosted / federated PDSes → direct PDS upload (with settle delay)
|
1) Always try video.bsky.app first (best playback compatibility).
|
||||||
* Official Bluesky network → video.bsky.app, fallback to PDS-direct
|
2) Optional direct-PDS fallback only if explicitly enabled.
|
||||||
"""
|
"""
|
||||||
if is_official_bsky_pds(service_url):
|
if is_official_bsky_pds(service_url):
|
||||||
logging.info(f"🌐 Detected official Bluesky PDS ({service_url}) — using video.bsky.app")
|
logging.info(f"🌐 PDS appears official ({service_url}). Using video.bsky.app.")
|
||||||
embed = upload_video_via_bsky_service(client, video_path, alt_text=alt_text)
|
else:
|
||||||
if embed:
|
logging.info(
|
||||||
return embed
|
f"🌍 PDS is self-hosted/federated ({service_url}). "
|
||||||
|
"Still trying video.bsky.app first for client playback reliability."
|
||||||
|
)
|
||||||
|
|
||||||
|
embed = upload_video_via_bsky_service(client, video_path, alt_text=alt_text)
|
||||||
|
if embed:
|
||||||
|
return embed
|
||||||
|
|
||||||
|
if allow_pds_video_fallback:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"⚠️ video.bsky.app upload failed; falling back to direct PDS upload."
|
"⚠️ video.bsky.app upload failed; trying direct PDS fallback "
|
||||||
|
"(may produce unplayable videos in some clients)."
|
||||||
)
|
)
|
||||||
return upload_video_via_pds(
|
return upload_video_via_pds(
|
||||||
client, video_path, alt_text=alt_text,
|
client,
|
||||||
|
video_path,
|
||||||
|
alt_text=alt_text,
|
||||||
settle_delay_seconds=settle_delay_seconds,
|
settle_delay_seconds=settle_delay_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
logging.info(f"🌍 Detected self-hosted/federated PDS ({service_url}) — using direct upload")
|
logging.error(
|
||||||
return upload_video_via_pds(
|
"❌ video.bsky.app upload failed. Not posting unreliable direct-PDS video "
|
||||||
client, video_path, alt_text=alt_text,
|
"(enable --allow-pds-video-fallback to override)."
|
||||||
settle_delay_seconds=settle_delay_seconds,
|
|
||||||
)
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -556,7 +566,8 @@ def post_to_bsky(
|
|||||||
video_path: str | None = None,
|
video_path: str | None = None,
|
||||||
alt_text: str = "",
|
alt_text: str = "",
|
||||||
service_url: str = "https://bsky.social",
|
service_url: str = "https://bsky.social",
|
||||||
video_settle_delay: float = 15.0,
|
video_settle_delay: float = 30.0,
|
||||||
|
allow_pds_video_fallback: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
rich_text = make_rich(text)
|
rich_text = make_rich(text)
|
||||||
|
|
||||||
@@ -570,6 +581,7 @@ def post_to_bsky(
|
|||||||
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,
|
||||||
|
allow_pds_video_fallback=allow_pds_video_fallback,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not video_embed:
|
if not video_embed:
|
||||||
@@ -613,25 +625,34 @@ def main():
|
|||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Post text + optional image or video to a Bluesky instance."
|
description="Post text + optional image or video to Bluesky."
|
||||||
)
|
)
|
||||||
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")
|
||||||
parser.add_argument("--password", required=True, help="Bluesky app password")
|
parser.add_argument("--password", required=True, help="Bluesky app password")
|
||||||
parser.add_argument("--service", default="https://bsky.social", help="Bluesky PDS URL")
|
parser.add_argument("--service", default="https://bsky.social", help="Bluesky PDS URL")
|
||||||
parser.add_argument("--lang", default="ca", help="Comma-separated language codes (e.g. ca,es)")
|
parser.add_argument("--lang", default="ca", help="Comma-separated language codes (e.g. ca,es)")
|
||||||
parser.add_argument("--image", default=None, help="Path to image file to attach")
|
parser.add_argument("--image", default=None, help="Path to image file to attach")
|
||||||
parser.add_argument("--video", default=None, help="Path to video file to attach")
|
parser.add_argument("--video", default=None, help="Path to video file to attach")
|
||||||
parser.add_argument("--alt", default="", help="Alt text for media")
|
parser.add_argument("--alt", default="", help="Alt text for media")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--video-settle-delay",
|
"--video-settle-delay",
|
||||||
type=float,
|
type=float,
|
||||||
default=15.0,
|
default=30.0,
|
||||||
help="Seconds to wait after PDS-direct video upload before posting (default: 15)",
|
help="Seconds to wait after direct-PDS video fallback upload before posting (default: 30)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--allow-pds-video-fallback",
|
||||||
|
action="store_true",
|
||||||
|
help="Allow direct-PDS fallback if video.bsky.app upload fails (less reliable playback).",
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.image and args.video:
|
||||||
|
logging.error("❌ Use either --image or --video, not both.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
client = Client(base_url=args.service)
|
client = Client(base_url=args.service)
|
||||||
success = login_with_backoff(
|
success = login_with_backoff(
|
||||||
client,
|
client,
|
||||||
@@ -656,6 +677,7 @@ def main():
|
|||||||
alt_text=args.alt,
|
alt_text=args.alt,
|
||||||
service_url=args.service,
|
service_url=args.service,
|
||||||
video_settle_delay=args.video_settle_delay,
|
video_settle_delay=args.video_settle_delay,
|
||||||
|
allow_pds_video_fallback=args.allow_pds_video_fallback,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not post_success:
|
if not post_success:
|
||||||
@@ -663,4 +685,4 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
Reference in New Issue
Block a user