Skip to content

feat(bluesky): support video upload and embedding - #110

Merged
paulocastellano merged 4 commits into
trypostit:mainfrom
Schrall:feat/bluesky-video-upload
Jun 23, 2026
Merged

feat(bluesky): support video upload and embedding#110
paulocastellano merged 4 commits into
trypostit:mainfrom
Schrall:feat/bluesky-video-upload

Conversation

@Schrall

@Schrall Schrall commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Problem

BlueskyPublisher only attaches images — it iterates media and calls uploadBlob() exclusively for isImage() 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 reports published, 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.uploadBlob like 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 as app.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):

  1. Resolve the real PDS host from the account's DID document (plc.directory for did:plc, /.well-known/did.json for did:web). Entryway accounts store https://bsky.social as meta.service, but the video service-auth audience must be the account's actual *.host.bsky.network PDS.
  2. Mint a service-auth token (com.atproto.server.getServiceAuth, lxm=com.atproto.repo.uploadBlob, aud=did:web:<pds-host>, 30‑min exp).
  3. Upload the bytes to app.bsky.video.uploadVideo (handles the 409 already_exists duplicate case by reusing the finished job's blob).
  4. Poll app.bsky.video.getJobStatus until JOB_STATE_COMPLETED, then embed the blob as app.bsky.embed.video.

Failure modes (download error, oversized > 100 MB, service-auth failure, JOB_STATE_FAILED, timeout) are logged and return null, so the post still publishes as text instead of crashing the queued job — consistent with how uploadBlob() already degrades for images.

New NSID constants live in BlueskyLexicon; the video service host, its DID, and the PLC directory are configurable via config/trypost.php (env‑overridable).

Tests

Adds three feature tests to BlueskyPublisherTest:

  • uploads a video and embeds it as app.bsky.embed.video, asserting it goes to the video service (never uploadBlob);
  • the upload service-auth audience is scoped to the resolved PDS host, not the entryway;
  • a JOB_STATE_FAILED transcode degrades to a text-only post rather than throwing.

Full BlueskyPublisherTest suite (22 tests) and pint both pass locally.

Notes / limitations

  • aspectRatio is intentionally omitted (optional in the lexicon) since dimensions aren't reliably available server-side without ffprobe. Videos still render; this can be added later.
  • No getUploadLimits preflight; an unverified-email / quota-exhausted account surfaces via the upload/job error path.

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>
Copilot AI review requested due to automatic review settings June 23, 2026 06:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 BlueskyPublisher to detect videos (when no image embed exists), upload to video.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.

Comment on lines +204 to +208
$videoService = (string) config('trypost.platforms.bluesky.video_service');
$did = (string) $account->platform_user_id;

$tempFile = tempnam(sys_get_temp_dir(), 'bsky_video_');

Comment on lines +299 to +301
} finally {
@unlink($tempFile);
}
Comment on lines +253 to +255
$stream = fopen($tempFile, 'r');

$response = $this->socialHttp()->withToken($uploadToken)
Comment on lines +398 to +401
} elseif (str_starts_with($did, 'did:web:')) {
$host = substr($did, strlen('did:web:'));
$docUrl = "https://{$host}/.well-known/did.json";
}
Comment on lines +322 to +326
$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>
@Schrall

Schrall commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review — all five points addressed in 7f648f8:

  1. tempnam() may return false (L208) — now guarded; bail with a log before entering the try block.
  2. @unlink($tempFile) TypeError on non-string (L301) — finally now guards with is_string().
  3. fopen() may return false (L255) — checked in the new attemptVideoUpload() helper; returns null if the file can't be opened.
  4. did:web with path segments (L401) — now maps colon segments to host + path per the did:web spec (did:web:example.com:user:alicehttps://example.com/user/alice/did.json); a bare host still uses /.well-known/did.json.
  5. pollVideoJob() ignored non-2xx getJobStatus (L326) — now bails early with a log on $response->failed() instead of sleeping to the timeout.

Additionally, while validating against the live video service I hit a transient JOB_STATE_FAILED "Failed to process video" for otherwise-valid input. The upload+poll is now retried up to VIDEO_UPLOAD_ATTEMPTS (3) times — extracted into attemptVideoUpload(), with the upload service-auth token minted once and reused across attempts. Added a test covering retry-after-transient-failure; full BlueskyPublisherTest suite (23) is green.

@paulocastellano

Copy link
Copy Markdown
Contributor

@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.
@paulocastellano
paulocastellano merged commit 0ae4b03 into trypostit:main Jun 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants