feat(bluesky): support video upload and embedding - #110
Conversation
BlueskyPublisher only attached images; posts whose media was a video
published as text-only with no embed. Bluesky videos do not use the PDS
uploadBlob path — they go through the separate video service
(video.bsky.app), which transcodes the file and writes the resulting blob
back to the account's PDS.
Add a video path that runs when no image embed was built (a post carries
either images or one video, never both):
1. Resolve the account's real PDS host from its DID document (entryway
accounts store bsky.social as `service`, but the video service-auth
audience must be the actual *.host.bsky.network PDS).
2. Mint a com.atproto.server.getServiceAuth token scoped to uploadBlob.
3. POST the bytes to app.bsky.video.uploadVideo.
4. Poll app.bsky.video.getJobStatus until the blob is ready, then embed
it as app.bsky.embed.video.
Failures (download, oversized >100MB, service-auth, transcode) log and
return null so the post still publishes as text rather than crashing the
job — mirroring uploadBlob(). New NSID constants live in BlueskyLexicon
and the video service / PLC directory hosts are configurable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds first-class Bluesky video support so posts with .mp4 media upload through the Bluesky video service, poll for transcode completion, and embed the resulting app.bsky.embed.video blob instead of silently publishing as text-only.
Changes:
- Extend
BlueskyPublisherto detect videos (when no image embed exists), upload tovideo.bsky.app, and poll job status until a blob is available for embedding. - Add configurable Bluesky video service + PLC directory endpoints in
config/trypost.php. - Add NSID constants and feature tests covering embed creation, service-auth audience scoping, and failure fallback to text-only.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
app/Services/Social/BlueskyPublisher.php |
Implements video upload/polling flow, DID-doc PDS resolution, and service-auth token minting for video embeds. |
app/Services/Social/BlueskyLexicon.php |
Adds lexicon NSID constants for service-auth and video endpoints/embeds. |
config/trypost.php |
Introduces env-configurable Bluesky video service host/DID and PLC directory URL. |
tests/Feature/Services/Social/BlueskyPublisherTest.php |
Adds feature tests and an HTTP fake pipeline for video upload + job polling behavior. |
| $videoService = (string) config('trypost.platforms.bluesky.video_service'); | ||
| $did = (string) $account->platform_user_id; | ||
|
|
||
| $tempFile = tempnam(sys_get_temp_dir(), 'bsky_video_'); | ||
|
|
| } finally { | ||
| @unlink($tempFile); | ||
| } |
| $stream = fopen($tempFile, 'r'); | ||
|
|
||
| $response = $this->socialHttp()->withToken($uploadToken) |
| } elseif (str_starts_with($did, 'did:web:')) { | ||
| $host = substr($did, strlen('did:web:')); | ||
| $docUrl = "https://{$host}/.well-known/did.json"; | ||
| } |
| $response = $this->socialHttp()->withToken($jobToken) | ||
| ->get($statusUrl, ['jobId' => $jobId]); | ||
|
|
||
| $jobStatus = data_get($response->json(), 'jobStatus'); | ||
| $state = data_get($jobStatus, 'state'); |
…ode failures Address review feedback and a transient failure mode seen in production: - Guard tempnam()/fopen() returning false (bail cleanly instead of a TypeError later) and guard the finally-block unlink() with is_string(). - pollVideoJob() now bails on a non-2xx getJobStatus (e.g. expired token) instead of sleeping to the timeout and masking the real error. - resolvePdsEndpoint() maps did:web colon segments to host + path per the did:web spec (did:web:example.com:user:alice -> .../user/alice/did.json) instead of treating the whole remainder as a host. - The video service occasionally fails a job transiently (JOB_STATE_FAILED "Failed to process video") for valid input, so the upload+poll is retried up to VIDEO_UPLOAD_ATTEMPTS times (split into a new attemptVideoUpload() helper; the upload service-auth token is minted once and reused across attempts). Adds a test covering retry-after-transient-failure. Full suite (23) green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the review — all five points addressed in
Additionally, while validating against the live video service I hit a transient |
|
@Schrall i will review it, thank you for submit! |
Builds on the video-upload feature with house-style cleanup and fixes: - Extract downloadToTempFile / unwrapJobStatus / videoUploadFormat helpers and guard tempnam/fopen on the image path too (no magic numbers; named consts). - Send the real content-type and extension for Bluesky's four accepted formats (mp4, mpeg, webm, mov), falling back to mp4 for anything else. - Mint the upload and status service-auth tokens once and reuse them. - Bound the whole upload/poll/retry flow to a wall-clock budget under the queue job timeout so a stuck transcode degrades to a text post instead of being killed mid-flight. - Config-drive the poll interval, max video size, and video-service hosts. - Expand BlueskyPublisherTest to cover every branch: 409 with and without a blob, upload/status token failures, did:plc fallback and did:web resolution, the format mapping, retries, and timeout.
A Bluesky post embed is images XOR video, so a post that carries both can't be published there. Enforce it per selected platform: - Add ContentType::supportsMixedMedia() (false only for BlueskyPost) and reject image+video in ContentTypeCompatibleWithMedia when the type forbids it. - Mirror it client-side (useMediaRules forbidsMixedMedia + usePostCompliance) so the editor blocks scheduling with an inline reason before submit, like every other per-platform compatibility check. - Add the no_mixed_media message in all three locales (en/es/pt-BR). - Cover the rule and enum, including the GIF-counts-as-image case.
Problem
BlueskyPublisheronly attaches images — it iterates media and callsuploadBlob()exclusively forisImage()items. When a scheduled post's media is a video, the media is silently skipped and the post publishes as text-only with no embed. The post platform still reportspublished, so the failure is invisible until you look at the live feed.Repro: schedule a Bluesky post whose only media is an
.mp4→ it goes out with no video.Why a separate path is needed
Bluesky videos are not uploaded to the PDS via
com.atproto.repo.uploadBloblike images. They go through the dedicated video service (video.bsky.app), which transcodes the file (HLS) and writes the resulting blob back to the account's PDS. Only then can the blob be embedded asapp.bsky.embed.video.What this PR does
Adds a video branch in
publish()that runs only when no image embed was built (a post carries either images or a single video, never both — mirroring the official client):plc.directoryfordid:plc,/.well-known/did.jsonfordid:web). Entryway accounts storehttps://bsky.socialasmeta.service, but the video service-auth audience must be the account's actual*.host.bsky.networkPDS.com.atproto.server.getServiceAuth,lxm=com.atproto.repo.uploadBlob,aud=did:web:<pds-host>, 30‑min exp).app.bsky.video.uploadVideo(handles the409 already_existsduplicate case by reusing the finished job's blob).app.bsky.video.getJobStatusuntilJOB_STATE_COMPLETED, then embed the blob asapp.bsky.embed.video.Failure modes (download error, oversized > 100 MB, service-auth failure,
JOB_STATE_FAILED, timeout) are logged and returnnull, so the post still publishes as text instead of crashing the queued job — consistent with howuploadBlob()already degrades for images.New NSID constants live in
BlueskyLexicon; the video service host, its DID, and the PLC directory are configurable viaconfig/trypost.php(env‑overridable).Tests
Adds three feature tests to
BlueskyPublisherTest:app.bsky.embed.video, asserting it goes to the video service (neveruploadBlob);JOB_STATE_FAILEDtranscode degrades to a text-only post rather than throwing.Full
BlueskyPublisherTestsuite (22 tests) andpintboth pass locally.Notes / limitations
aspectRatiois intentionally omitted (optional in the lexicon) since dimensions aren't reliably available server-side without ffprobe. Videos still render; this can be added later.getUploadLimitspreflight; an unverified-email / quota-exhausted account surfaces via the upload/job error path.