fff
This commit is contained in:
92
bsky_post.py
92
bsky_post.py
@@ -7,15 +7,7 @@ Usage examples:
|
|||||||
python3 bsky_post.py "Dijous!!!!" --image thursday.jpg
|
python3 bsky_post.py "Dijous!!!!" --image thursday.jpg
|
||||||
python3 bsky_post.py "Bon dia!"
|
python3 bsky_post.py "Bon dia!"
|
||||||
python3 bsky_post.py "Bon dia!" --image photo.jpg --lang ca
|
python3 bsky_post.py "Bon dia!" --image photo.jpg --lang ca
|
||||||
|
python3 bsky_post.py "Long video!" --video clip.mp4 --video-settle-delay 25
|
||||||
Notes:
|
|
||||||
* Self-hosted / federated PDSes (e.g. eurosky.social) upload videos
|
|
||||||
DIRECTLY to the user's PDS as a generic blob — no video.bsky.app
|
|
||||||
service auth is required.
|
|
||||||
* The official Bluesky network (bsky.social, *.bsky.network) uses the
|
|
||||||
centralized video.bsky.app pipeline with a service-auth token scoped
|
|
||||||
via `lxm` to `app.bsky.video.uploadVideo`.
|
|
||||||
* The script auto-detects which path to use based on --service.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -153,9 +145,6 @@ def is_transient_error(error_obj) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: float) -> float:
|
def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: float) -> float:
|
||||||
"""
|
|
||||||
Parse common rate-limit headers and return a bounded wait time in seconds.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
now_ts = int(time.time())
|
now_ts = int(time.time())
|
||||||
|
|
||||||
@@ -341,6 +330,29 @@ def upload_image(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Helpers — settle delay
|
||||||
|
# ============================================================
|
||||||
|
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)
|
# Media upload — Video (PDS-direct)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -348,6 +360,7 @@ 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 = 15.0,
|
||||||
) -> models.AppBskyEmbedVideo.Main | None:
|
) -> models.AppBskyEmbedVideo.Main | None:
|
||||||
"""
|
"""
|
||||||
Upload a video as a generic blob directly to the user's PDS.
|
Upload a video as a generic blob directly to the user's PDS.
|
||||||
@@ -355,6 +368,12 @@ def upload_video_via_pds(
|
|||||||
Used for self-hosted / federated PDSes (e.g. eurosky.social) that don't
|
Used for self-hosted / federated PDSes (e.g. eurosky.social) that don't
|
||||||
proxy to the centralized video.bsky.app service. The PDS stores the bytes
|
proxy to the centralized video.bsky.app service. The PDS stores the bytes
|
||||||
and returns a blob ref we can embed directly.
|
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):
|
||||||
@@ -365,11 +384,16 @@ 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(f"🎬 [PDS-direct] Uploading video to home PDS: {video_path} ({size_mb:.2f} MB)")
|
logging.info(
|
||||||
|
f"🎬 [PDS-direct] Uploading video 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(f"✅ [PDS-direct] Video blob uploaded successfully.")
|
logging.info("✅ [PDS-direct] Video blob uploaded successfully.")
|
||||||
|
|
||||||
|
# Give PDS / AppView time to index the blob before we reference it.
|
||||||
|
wait_with_heartbeat(settle_delay_seconds, label="to index the video blob")
|
||||||
|
|
||||||
return models.AppBskyEmbedVideo.Main(
|
return models.AppBskyEmbedVideo.Main(
|
||||||
video=blob,
|
video=blob,
|
||||||
@@ -402,12 +426,13 @@ 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(f"🎬 [video.bsky.app] Uploading via shared service: {video_path} ({size_mb:.2f} MB)")
|
logging.info(
|
||||||
|
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"
|
||||||
|
|
||||||
# --- Service auth token (lxm-scoped → 30 min OK) ---
|
|
||||||
upload_auth = client.com.atproto.server.get_service_auth({
|
upload_auth = client.com.atproto.server.get_service_auth({
|
||||||
"aud": VIDEO_DID,
|
"aud": VIDEO_DID,
|
||||||
"lxm": "app.bsky.video.uploadVideo",
|
"lxm": "app.bsky.video.uploadVideo",
|
||||||
@@ -439,9 +464,8 @@ def upload_video_via_bsky_service(
|
|||||||
|
|
||||||
logging.info(f"⏳ Job {job_id} accepted — polling status...")
|
logging.info(f"⏳ Job {job_id} accepted — polling status...")
|
||||||
|
|
||||||
# getJobStatus is unauthenticated on video.bsky.app
|
|
||||||
status_url = f"{VIDEO_HOST}/xrpc/app.bsky.video.getJobStatus"
|
status_url = f"{VIDEO_HOST}/xrpc/app.bsky.video.getJobStatus"
|
||||||
deadline = time.time() + 300 # 5-minute ceiling
|
deadline = time.time() + 300
|
||||||
|
|
||||||
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)
|
||||||
@@ -493,14 +517,12 @@ def upload_video_smart(
|
|||||||
video_path: str,
|
video_path: str,
|
||||||
service_url: str,
|
service_url: str,
|
||||||
alt_text: str = "",
|
alt_text: str = "",
|
||||||
|
settle_delay_seconds: float = 15.0,
|
||||||
) -> models.AppBskyEmbedVideo.Main | None:
|
) -> models.AppBskyEmbedVideo.Main | None:
|
||||||
"""
|
"""
|
||||||
Smart dispatcher: pick PDS-direct or video.bsky.app based on the PDS.
|
Smart dispatcher:
|
||||||
|
* Self-hosted / federated PDSes → direct PDS upload (with settle delay)
|
||||||
* Self-hosted / federated PDSes (eurosky.social etc.) → direct PDS upload
|
* Official Bluesky network → video.bsky.app, fallback to PDS-direct
|
||||||
(no service auth needed; PDS stores video itself).
|
|
||||||
* Official Bluesky network (bsky.social, *.bsky.network) → video.bsky.app
|
|
||||||
with lxm-scoped service auth, falling back to PDS-direct on failure.
|
|
||||||
"""
|
"""
|
||||||
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"🌐 Detected official Bluesky PDS ({service_url}) — using video.bsky.app")
|
||||||
@@ -511,10 +533,16 @@ def upload_video_smart(
|
|||||||
logging.warning(
|
logging.warning(
|
||||||
"⚠️ video.bsky.app upload failed; falling back to direct PDS upload."
|
"⚠️ video.bsky.app upload failed; falling back to direct PDS upload."
|
||||||
)
|
)
|
||||||
return upload_video_via_pds(client, video_path, alt_text=alt_text)
|
return upload_video_via_pds(
|
||||||
|
client, video_path, alt_text=alt_text,
|
||||||
|
settle_delay_seconds=settle_delay_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
logging.info(f"🌍 Detected self-hosted/federated PDS ({service_url}) — using direct upload")
|
logging.info(f"🌍 Detected self-hosted/federated PDS ({service_url}) — using direct upload")
|
||||||
return upload_video_via_pds(client, video_path, alt_text=alt_text)
|
return upload_video_via_pds(
|
||||||
|
client, video_path, alt_text=alt_text,
|
||||||
|
settle_delay_seconds=settle_delay_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -528,11 +556,11 @@ 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,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
rich_text = make_rich(text)
|
rich_text = make_rich(text)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# --- VIDEO POSTING ---
|
|
||||||
if video_path:
|
if video_path:
|
||||||
logging.info(f"🎬 Preparing video upload: {video_path}")
|
logging.info(f"🎬 Preparing video upload: {video_path}")
|
||||||
|
|
||||||
@@ -541,6 +569,7 @@ def post_to_bsky(
|
|||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not video_embed:
|
if not video_embed:
|
||||||
@@ -554,7 +583,6 @@ def post_to_bsky(
|
|||||||
langs=langs,
|
langs=langs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- IMAGE POSTING ---
|
|
||||||
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)
|
||||||
if not image:
|
if not image:
|
||||||
@@ -565,7 +593,6 @@ def post_to_bsky(
|
|||||||
logging.info("🚀 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 ---
|
|
||||||
else:
|
else:
|
||||||
logging.info("🚀 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)
|
||||||
@@ -596,6 +623,12 @@ def main():
|
|||||||
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(
|
||||||
|
"--video-settle-delay",
|
||||||
|
type=float,
|
||||||
|
default=15.0,
|
||||||
|
help="Seconds to wait after PDS-direct video upload before posting (default: 15)",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -622,6 +655,7 @@ def main():
|
|||||||
video_path=args.video,
|
video_path=args.video,
|
||||||
alt_text=args.alt,
|
alt_text=args.alt,
|
||||||
service_url=args.service,
|
service_url=args.service,
|
||||||
|
video_settle_delay=args.video_settle_delay,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not post_success:
|
if not post_success:
|
||||||
|
|||||||
Reference in New Issue
Block a user