Skip to content

Fix Facebook video stories: upload through rupload with file_url - #361

Merged
paulocastellano merged 15 commits into
mainfrom
fix/facebook-story-file-url
Sep 18, 2026
Merged

paulocastellano merged 15 commits into
mainfrom
fix/facebook-story-file-url

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Problem

Publishing a Facebook video Story failed at finish with Graph error 6000 ("Problem with file. Try with another file."). Users saw "There is an error with the file" after converting to MP4 (Nightwatch issue #42, 19 occurrences in 7 days; also reported via Crisp).

Root cause: the transfer step posted upload_phase=transfer + video_file_chunk=<url> to the Graph /{video_id} node. Those are Page Videos API parameters that the Stories flow never reads. Meta never fetched the file, the upload_url session returned by start stayed empty, and finish rejected it.

Diagnosis credit: @Cryptoom in #360.

Fix — Stories and Reels

Both follow the official hosted-file variant of Meta's resumable video flow (Page Stories API, Reels Publishing API):

  1. POST /{page}/{video_stories|video_reels} upload_phase=startvideo_id + upload_url
  2. Validate upload_url is https:// on trypost.platforms.facebook.rupload_host (rupload.facebook.com)
  3. POST {upload_url} with headers Authorization: OAuth … and file_url: <our CDN URL>, no body; require {"success": true}
  4. Poll GET /{video_id}?fields=status (5 s, up to 5 min, tolerant to transient 5xx/429) until uploading_phase.status = complete; surface processing_phase.error.message to the user on failure
  5. POST … upload_phase=finish

Meta fetches the file from our CDN, so no video bytes pass through the worker any more. Reels previously downloaded the file to a temp file and streamed it with Offset/file_size; that path existed because file_url had once been sent in the body instead of as a header. Story was verified live on this branch (published in 28 s); Reel still needs one live publish before merge.

Also in this PR

  • Connection failures reschedule instead of failing. DNS/TCP/TLS timeouts on any Graph, rupload or CDN call map to PlatformUnavailableException (retry in 60 s), matching the Instagram publisher. cURL quotes the full URL in its message, so it is redacted before reaching error_context.
  • FacebookPublisher tidy-up. Every Graph POST goes through postToGraph(), optional payload fields are built inline, media params are typed, and Reels/Stories reject missing or non-video media with a typed exception before any request.
  • Facebook post metrics per content type. Stories only answer the story metric family and Reels expose video_insights, so asking either for post_impressions was a #100 rejection. FacebookAnalytics now picks edge and metrics by content type, labels through analytics.metrics.* (new reactions key in all 16 locales) and drops post_impressions*, which Meta deprecates above Graph API v25.
  • Content type badge on the post page. A Facebook card now says Reel / Story / Post (Instagram and Pinterest too); single-format platforms are untouched.

Scope

Facebook only. Feed posts, photos, carousels and timeline videos keep their exact HTTP calls. Instagram is not touched.

Tests

  • FacebookPublisherTest — 73 tests. The shared video flow runs against Reel and Story through a dataset: hosted URL by header with no CDN download, poll before finish, start without upload_url, invalid upload_url (other host, http, userinfo trick, non-URL), rupload 400 mapped, rupload without success, rupload/status unreachable, transient 5xx during poll, confirmed rejection, processing error surfaced, expired session, poll timeout, token redaction, configurable rupload host.
  • FacebookAnalyticsTest — new, 7 tests: post/reel/story metric sets, unrequested metrics ignored, no deprecated metrics, API rejection, missing id.
  • PostControllerTest — show page exposes content_type; tests/Browser/PostShowContentTypeTest.php checks the badge renders only where the format was a choice.

Supersedes #360.

The story transfer posted video_file_chunk=<url> to the Graph /{video_id}
node, a Page Videos API parameter the Stories flow never reads. Meta never
fetched the file, the upload_url session stayed empty, and finish failed
with error 6000 "Problem with file".

Send the hosted URL as the file_url header to the upload_url the start
phase returns, require success:true, then poll /{video_id}?fields=status
until the uploading phase completes before finish. The upload_url host is
pinned to config so the Page token never leaves rupload.facebook.com.

Reels, feed posts, photos and timeline videos are untouched.
Paulo Castellano added 14 commits September 18, 2026 09:39
Route every Graph POST through postToGraph so logging and error mapping
live in one place, share the start phase between Reels and Stories
(including the rupload host check), build optional payload fields
inline, and type the media parameters. Reels and Stories now reject a
missing or non-video media with a typed exception before any request.
HTTP calls, payloads and headers are unchanged.
A 5xx or rate limit on the status check aborted the publish after
rupload had already accepted the video, orphaning the session and
forcing a full re-upload on retry. Transient failures now log and wait
for the next poll; confirmed rejections still fail immediately. Also
restore the ??-fallback for the finish ids so a null id falls back to
video_id as before.
A connection that never completes (DNS, TCP or TLS timeout) surfaced as
an unexpected error and marked the post failed, even though Meta had
received nothing. Every Graph, rupload and media-download call now maps
ConnectionException to PlatformUnavailableException so the job retries
in 60 seconds, matching the Instagram publisher.
Stories and Reels are different Graph nodes from feed posts: a Story only
answers the story metric family and a Reel exposes video_insights, so
asking either for post_impressions was a #100 rejection and the post page
showed nothing. Pick edge and metrics by content type, label them through
analytics.metrics.*, and drop the post_impressions family, which Meta
deprecates above Graph API v25, in favour of the media_view metrics.
Adds analytics.metrics.reactions to all locales.
A Facebook card read the same for a Reel, a Story and a feed post. Tag the
format where it was a choice (Facebook, Instagram, Pinterest) using the
existing posts.content_types labels; single-format platforms stay as they
are.
hasContentTypeChoice() names the 'did the user pick a format here' check
next to the platform/content-type map that answers it, and the i18n key
is built in one place shared with getContentTypeOptions(). The post page
resolves the key once per platform instead of calling a helper twice in
the template.
translationKeyFor builds the i18n key, hasMultipleContentTypes asks
whether the platform offers a choice, and getContentTypeBadgeKey resolves
what the badge shows. The three previous names differed by one word.
Partial carousel upload, whitespace-only text, reel rupload rejection,
empty reel download and an unreachable CDN each get a test, all checking
that finish is never sent and the reel temp file is gone. An empty
download now says so instead of reporting a preparation failure.
The Reels Publishing API accepts the same file_url header on the rupload
URL that Stories use, so the worker no longer downloads the video, opens
a temp file and streams the bytes with Offset/file_size. Reels and
Stories now share uploadVideo(): start, hand Meta the CDN URL, poll the
status until the fetch completes, finish. The old byte upload existed
because file_url had once been sent in the body instead of as a header.

The shared flow's tests run against both formats through a dataset.
cURL quotes the full URL in a timeout message, and the status poll carries
access_token in its query string, so a connection failure there wrote the
token into error_context and the logs. The message now goes through the
redactor, with a test that forces the real URL into the exception.

The poll sleeps once at the top of each retry instead of in two places,
so a timeout no longer waits five seconds before giving up. A photo upload
that returns no id is logged like a failed one, and the rupload host check
is exercised with http, a userinfo trick and a non-URL as well.
An insight we did not ask for used to render its raw translation key as
the label; it is now dropped. The post page's content_type prop and the
badge itself get a feature test and a browser test.
A timeline video is stored as the bare video id Meta returns from
/videos, the same kind of node as a Reel, and a video node has no
/insights edge at all. Choosing metrics by content type therefore sent
timeline videos to a #100 rejection and the post page showed nothing.

Feed posts are {page_id}_{post_id} and keep the post_* metrics; any bare
id reads /video_insights. Reels also do not answer the documented
total_video_* names, so the video set is the one Meta actually returns
for short videos: fb_reels_total_plays, likes by reaction type and
social actions, the last two summed from their per-type breakdown.
Verified live against a Reel, a timeline video and a Story.
Without a period Meta answers some post metrics twice, once per period,
so the card would have shown Reach and Likes two times. Every insights
call now asks for lifetime values only; verified on a feed post, a video
and a story node.
@paulocastellano
paulocastellano merged commit 4cdf279 into main Sep 18, 2026
5 checks passed
@paulocastellano
paulocastellano deleted the fix/facebook-story-file-url branch September 18, 2026 14:37
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.

1 participant