Fix Facebook video stories: upload through rupload with file_url - #361
Merged
Merged
Conversation
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.
3 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Publishing a Facebook video Story failed at
finishwith 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, theupload_urlsession returned bystartstayed empty, andfinishrejected 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):
POST /{page}/{video_stories|video_reels} upload_phase=start→video_id+upload_urlupload_urlishttps://ontrypost.platforms.facebook.rupload_host(rupload.facebook.com)POST {upload_url}with headersAuthorization: OAuth …andfile_url: <our CDN URL>, no body; require{"success": true}GET /{video_id}?fields=status(5 s, up to 5 min, tolerant to transient 5xx/429) untiluploading_phase.status = complete; surfaceprocessing_phase.error.messageto the user on failurePOST … upload_phase=finishMeta 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 becausefile_urlhad 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
PlatformUnavailableException(retry in 60 s), matching the Instagram publisher. cURL quotes the full URL in its message, so it is redacted before reachingerror_context.FacebookPublishertidy-up. Every Graph POST goes throughpostToGraph(), 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.video_insights, so asking either forpost_impressionswas a#100rejection.FacebookAnalyticsnow picks edge and metrics by content type, labels throughanalytics.metrics.*(newreactionskey in all 16 locales) and dropspost_impressions*, which Meta deprecates above Graph API v25.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 withoutupload_url, invalidupload_url(other host,http, userinfo trick, non-URL), rupload 400 mapped, rupload withoutsuccess, 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 exposescontent_type;tests/Browser/PostShowContentTypeTest.phpchecks the badge renders only where the format was a choice.Supersedes #360.