Skip to content

Commit 4c22d70

Browse files
authored
Add Reel Facebook crosspost payload helper (#2478)
1 parent aa12c45 commit 4c22d70

5 files changed

Lines changed: 267 additions & 5 deletions

File tree

.github/workflows/live-account-tests.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ jobs:
4949
run: |
5050
pytest -sv \
5151
tests/live/test_upload.py::ClientFeedMusicUploadLiveTestCase \
52-
tests/live/test_upload.py::ClientTrialReelUploadLiveTestCase
52+
tests/live/test_upload.py::ClientTrialReelUploadLiveTestCase \
53+
tests/live/test_upload.py::ClientFacebookReelCrosspostLiveTestCase
5354
5455
- name: Run direct media test
5556
if: github.event.inputs.test_target == 'direct' || github.event.inputs.test_target == 'all'

docs/usage-guide/media.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,9 +308,11 @@ Upload medias to your feed. Common arguments:
308308
| video_upload(path: Path, caption: str, thumbnail: Path, usertags: List[Usertag], location: Location, extra_data: Dict = {}) | Media | Upload video (Support MP4 files)
309309
| album_upload(paths: List[Path], caption: str, usertags: List[Usertag], location: Location, extra_data: Dict = {}) | Media | Upload Album (Support JPG/MP4 files)
310310
| igtv_upload(path: Path, title: str, caption: str, thumbnail: Path, usertags: List[Usertag], location: Location, extra_data: Dict = {}) | Media | Upload IGTV (Support MP4 files)
311-
| clip_upload(path: Path, caption: str, thumbnail: Path, usertags: List[Usertag], location: Location, extra_data: Dict = {}, trial: bool = False, trial_graduation_strategy: str = "manual") | Media | Upload Reels Clip (Support MP4 files). Set `trial=True` to publish a Trial Reel on eligible accounts
311+
| clip_upload(path: Path, caption: str, thumbnail: Path, usertags: List[Usertag], location: Location, extra_data: Dict = {}, trial: bool = False, trial_graduation_strategy: str = "manual", share_to_facebook: bool = False) | Media | Upload Reels Clip (Support MP4 files). Set `trial=True` to publish a Trial Reel on eligible accounts. Set `share_to_facebook=True` to cross-post to a linked Facebook destination
312312
| clip_trial_eligible() | bool | Check whether Reel creation preflight reports Trial Reels enabled before uploading video bytes
313313
| clip_info_for_creation() | dict | Get Reel creation preflight configuration from the mobile API
314+
| clip_share_to_fb_config() | dict | Get Reel Facebook sharing configuration from the mobile API
315+
| clip_share_to_fb_extra_data(config: Dict = None, destination_id: str = None, destination_type: str = None) | dict | Build modern Reel Facebook cross-post configure fields for manual `extra_data`
314316
| clip_upload_as_reel_with_music(path: Path, caption: str, track: Track, extra_data: Dict = {}) | Media | Upload Reels Clip as reel with music metadata
315317
| photo_upload_with_music(path: Path, caption: str, track: Track or dict, extra_data: Dict = {}) | Media | Upload feed photo with music metadata
316318
| album_upload_with_music(paths: List[Path], caption: str, track: Track or dict, extra_data: Dict = {}) | Media | Upload feed album/carousel with music metadata
@@ -333,6 +335,13 @@ Reel composer does not report Trial Reels enabled. Instagram can still reject Tr
333335
configure, so keep upload-side error handling for backend eligibility decisions. When `trial=True`, `clip_upload` sends
334336
`trial_params={"graduation_strategy": "manual"}` by default and disables feed preview for the upload.
335337

338+
Facebook Reel sharing requires a Facebook account/page linked in the Instagram app. Modern Android app builds no longer
339+
use only `{"share_to_facebook": 1}` for Reels; they also send destination and cross-posting fields. Use
340+
`clip_upload(..., share_to_facebook=True)` to fetch `clip_share_to_fb_config()` and build the configure payload before
341+
video bytes are uploaded. If the account is not linked or Instagram does not return a Reel Facebook destination,
342+
instagrapi raises `ClientError` before upload. Advanced callers can pass `fb_destination_id` and `fb_destination_type`,
343+
or build `extra_data` manually with `clip_share_to_fb_extra_data(...)`.
344+
336345
### Example:
337346

338347
``` python
@@ -348,6 +357,22 @@ configure, so keep upload-side error handling for backend eligibility decisions.
348357
... trial=True,
349358
... )
350359

360+
>>> reel = cl.clip_upload(
361+
... "/app/reel.mp4",
362+
... "Cross-posting this Reel to Facebook",
363+
... share_to_facebook=True,
364+
... )
365+
366+
>>> fb_extra = cl.clip_share_to_fb_extra_data(
367+
... destination_id="FACEBOOK_DESTINATION_ID",
368+
... destination_type="DESTINATION_TYPE_FROM_APP_CONFIG",
369+
... )
370+
>>> reel = cl.clip_upload(
371+
... "/app/reel.mp4",
372+
... "Cross-posting with explicit Facebook destination",
373+
... extra_data=fb_extra,
374+
... )
375+
351376
>>> media = cl.photo_upload(
352377
"/app/image.jpg",
353378
"Test caption for photo with #hashtags and mention users such @example",

instagrapi/mixins/clip.py

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,95 @@ def clip_share_to_fb_config(self, device_status: Optional[Dict[str, object]] = N
184184
params={"device_status": json.dumps(device_status)},
185185
)
186186

187+
def clip_share_to_fb_extra_data(
188+
self,
189+
config: Optional[Dict[str, object]] = None,
190+
destination_id: Optional[str] = None,
191+
destination_type: Optional[str] = None,
192+
destination_audience_type: Optional[str] = None,
193+
xpost_surface: str = "IG_REELS_COMPOSER",
194+
validation_check_bypass: Optional[bool] = None,
195+
) -> Dict[str, object]:
196+
"""
197+
Build configure fields for sharing a Reel to Facebook.
198+
199+
Instagram Android 428 stores Reel Facebook cross-posting in
200+
``XPlatformParams`` fields. The old ``share_to_facebook`` flag alone is
201+
not enough for accounts that require an explicit Facebook destination.
202+
203+
Parameters
204+
----------
205+
config: Dict[str, object], optional
206+
Response from ``clip_share_to_fb_config()``. When omitted, this
207+
method fetches it.
208+
destination_id: str, optional
209+
Facebook destination id. Overrides config values.
210+
destination_type: str, optional
211+
Facebook destination type/posting type. Overrides config values.
212+
destination_audience_type: str, optional
213+
Facebook Reels audience type, e.g. ``PUBLIC``.
214+
xpost_surface: str, optional
215+
Cross-posting surface reported by the Instagram app.
216+
validation_check_bypass: bool, optional
217+
Whether to bypass app-side FB validation. Overrides config values.
218+
219+
Returns
220+
-------
221+
Dict
222+
Extra configure data for ``clip_upload(..., extra_data=...)``.
223+
"""
224+
fb_config = (config if config is not None else self.clip_share_to_fb_config()) or {}
225+
if fb_config.get("share_to_fb_unavailable"):
226+
raise ClientError("Facebook Reel sharing is unavailable for this account")
227+
if fb_config.get("enabled") is False or fb_config.get("is_account_linked") is False:
228+
raise ClientError("Facebook Reel sharing is not enabled or no Facebook account is linked")
229+
230+
destination_id = (
231+
destination_id
232+
or fb_config.get("share_to_fb_destination_id")
233+
or fb_config.get("reels_destination_id")
234+
or fb_config.get("destination_id")
235+
)
236+
destination_type = (
237+
destination_type
238+
or fb_config.get("share_to_fb_destination_type")
239+
or fb_config.get("reels_cross_app_share_type")
240+
or fb_config.get("posting_type")
241+
)
242+
destination_audience_type = (
243+
destination_audience_type
244+
or fb_config.get("share_to_fb_destination_audience_type")
245+
or fb_config.get("reels_destination_audience_type")
246+
or fb_config.get("audience_type")
247+
)
248+
if validation_check_bypass is None:
249+
validation_check_bypass = fb_config.get("reels_cross_app_share_fb_validation_check_bypass")
250+
251+
if not destination_id:
252+
raise ClientError(
253+
"Facebook Reel sharing configuration has no destination. "
254+
"Link a Facebook account/page in the Instagram app or pass destination_id."
255+
)
256+
if not destination_type:
257+
raise ClientError(
258+
"Facebook Reel sharing configuration has no destination type. "
259+
"Pass destination_type from a linked-account app capture."
260+
)
261+
262+
data = {
263+
"share_to_facebook": "1",
264+
"is_reel_shared_to_fb": True,
265+
"share_to_facebook_reels": True,
266+
"share_to_fb_destination_id": destination_id,
267+
"share_to_fb_destination_type": destination_type,
268+
"xpost_surface": xpost_surface,
269+
}
270+
if destination_audience_type:
271+
data["share_to_fb_destination_audience_type"] = destination_audience_type
272+
if validation_check_bypass is not None:
273+
data["cross_app_share_fb_validation_check_bypass"] = bool(validation_check_bypass)
274+
return data
275+
187276
def clip_upload(
188277
self,
189278
path: Path,
@@ -196,6 +285,12 @@ def clip_upload(
196285
extra_data: Dict[str, object] = {},
197286
trial: bool = False,
198287
trial_graduation_strategy: str = "manual",
288+
share_to_facebook: bool = False,
289+
fb_destination_id: Optional[str] = None,
290+
fb_destination_type: Optional[str] = None,
291+
fb_destination_audience_type: Optional[str] = None,
292+
fb_xpost_surface: str = "IG_REELS_COMPOSER",
293+
fb_validation_check_bypass: Optional[bool] = None,
199294
) -> Media:
200295
"""
201296
Upload CLIP to Instagram
@@ -220,11 +315,23 @@ def clip_upload(
220315
Forced to "0" for Trial Reels.
221316
extra_data: Dict[str, object], optional
222317
Dict of extra data, if you need to add your params,
223-
like {"share_to_facebook": 1}.
318+
like {"disable_comments": 1}.
224319
trial: bool, optional
225320
Upload as a Trial Reel for eligible accounts, default is False.
226321
trial_graduation_strategy: str, optional
227322
Trial Reel graduation strategy, default is "manual".
323+
share_to_facebook: bool, optional
324+
Share this Reel to a linked Facebook account/page, default is False.
325+
fb_destination_id: str, optional
326+
Facebook destination id used when share_to_facebook is True.
327+
fb_destination_type: str, optional
328+
Facebook destination type used when share_to_facebook is True.
329+
fb_destination_audience_type: str, optional
330+
Facebook Reels audience type, e.g. ``PUBLIC``.
331+
fb_xpost_surface: str, optional
332+
Cross-posting surface reported by the Instagram app.
333+
fb_validation_check_bypass: bool, optional
334+
Override the validation bypass value from share_to_fb_config.
228335
229336
Returns
230337
-------
@@ -240,6 +347,15 @@ def clip_upload(
240347
clip_data = fp.read()
241348
clip_len = str(len(clip_data))
242349
configure_extra_data = dict(extra_data or {})
350+
if share_to_facebook:
351+
fb_extra_data = self.clip_share_to_fb_extra_data(
352+
destination_id=fb_destination_id,
353+
destination_type=fb_destination_type,
354+
destination_audience_type=fb_destination_audience_type,
355+
xpost_surface=fb_xpost_surface,
356+
validation_check_bypass=fb_validation_check_bypass,
357+
)
358+
configure_extra_data = {**fb_extra_data, **configure_extra_data}
243359
if trial:
244360
feed_show = "0"
245361
configure_extra_data.setdefault(
@@ -439,7 +555,7 @@ def clip_upload_as_reel_with_music(
439555
use cl.search_music(title)[0].dict()
440556
441557
extra_data: Dict[str, object], optional
442-
Dict of extra data, if you need to add your params, like {"share_to_facebook": 1}.
558+
Dict of extra data, if you need to add your params, like {"disable_comments": 1}.
443559
444560
Returns
445561
-------
@@ -549,7 +665,7 @@ def clip_configure(
549665
location: Location, optional
550666
Location tag for this upload, default is None
551667
extra_data: Dict[str, object], optional
552-
Dict of extra data, if you need to add your params, like {"share_to_facebook": 1}.
668+
Dict of extra data, if you need to add your params, like {"disable_comments": 1}.
553669
554670
Returns
555671
-------

tests/live/test_upload.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,32 @@ def test_clip_upload_trial_live(self):
348348
if checked:
349349
self.skipTest(f"Instagram rejected {rejected}/{checked} clip_trial_eligible accounts for trial Clips")
350350
self.skipTest("No fresh account has Trial Reels enabled")
351+
352+
353+
class ClientFacebookReelCrosspostLiveTestCase(_helpers.ClientPrivateTestCase):
354+
def __init__(self, *args, **kwargs):
355+
self.cl = None
356+
return unittest.TestCase.__init__(self, *args, **kwargs)
357+
358+
def setUp(self):
359+
if not TEST_ACCOUNTS_URL:
360+
self.skipTest("TEST_ACCOUNTS_URL is required for Reel Facebook crosspost live tests")
361+
try:
362+
self.cl = self.fresh_account()
363+
except RuntimeError as exc:
364+
self.skipTest(str(exc))
365+
366+
def test_clip_share_to_fb_extra_data_live(self):
367+
config = self.cl.clip_share_to_fb_config()
368+
self.assertEqual(config.get("status"), "ok")
369+
try:
370+
extra_data = self.cl.clip_share_to_fb_extra_data(config=config)
371+
except ClientError as exc:
372+
self.skipTest(f"No linked Facebook Reel destination available: {exc}")
373+
374+
self.assertEqual(extra_data["share_to_facebook"], "1")
375+
self.assertTrue(extra_data["share_to_facebook_reels"])
376+
self.assertTrue(extra_data["is_reel_shared_to_fb"])
377+
self.assertTrue(extra_data["share_to_fb_destination_id"])
378+
self.assertTrue(extra_data["share_to_fb_destination_type"])
379+
self.assertEqual(extra_data["xpost_surface"], "IG_REELS_COMPOSER")

tests/regression/test_upload.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,47 @@ def test_clip_share_to_fb_config_requests_reel_facebook_config(self):
7878
self.assertFalse(device_status["hw_av1_dec"])
7979
self.assertEqual(result, expected)
8080

81+
def test_clip_share_to_fb_extra_data_builds_current_reel_crosspost_payload(self):
82+
client = self.build_client()
83+
config = {
84+
"enabled": True,
85+
"is_account_linked": True,
86+
"reels_share_to_facebook": True,
87+
"reels_destination_id": "fb-destination-id",
88+
"reels_cross_app_share_type": "2",
89+
"reels_cross_app_share_fb_validation_check_bypass": True,
90+
"status": "ok",
91+
}
92+
93+
result = client.clip_share_to_fb_extra_data(config=config)
94+
95+
self.assertEqual(
96+
result,
97+
{
98+
"share_to_facebook": "1",
99+
"is_reel_shared_to_fb": True,
100+
"share_to_facebook_reels": True,
101+
"share_to_fb_destination_id": "fb-destination-id",
102+
"share_to_fb_destination_type": "2",
103+
"cross_app_share_fb_validation_check_bypass": True,
104+
"xpost_surface": "IG_REELS_COMPOSER",
105+
},
106+
)
107+
108+
def test_clip_share_to_fb_extra_data_raises_without_destination(self):
109+
client = self.build_client()
110+
111+
with self.assertRaises(ClientError) as ctx:
112+
client.clip_share_to_fb_extra_data(
113+
config={
114+
"enabled": True,
115+
"default_share_to_fb_enabled": False,
116+
"status": "ok",
117+
}
118+
)
119+
120+
self.assertIn("Facebook Reel sharing configuration has no destination", str(ctx.exception))
121+
81122
def test_clip_info_for_creation_requests_reel_creation_config(self):
82123
client = self.build_client()
83124
expected = {
@@ -490,6 +531,56 @@ def test_clip_upload_trial_preserves_explicit_trial_params(self):
490531
},
491532
)
492533

534+
def test_clip_upload_share_to_facebook_adds_crosspost_params_before_upload(self):
535+
client = self.build_client()
536+
client.last_json = {"media": self.build_media_payload()}
537+
ok_response = Mock(status_code=200)
538+
extra_data = {"disable_comments": "1"}
539+
fb_extra = {
540+
"share_to_facebook": "1",
541+
"is_reel_shared_to_fb": True,
542+
"share_to_facebook_reels": True,
543+
"share_to_fb_destination_id": "fb-destination-id",
544+
"share_to_fb_destination_type": "2",
545+
"cross_app_share_fb_validation_check_bypass": False,
546+
"xpost_surface": "IG_REELS_COMPOSER",
547+
}
548+
549+
with mock.patch.object(client, "clip_share_to_fb_extra_data", return_value=fb_extra) as share_to_fb_extra:
550+
with mock.patch(
551+
"instagrapi.mixins.clip.analyze_video",
552+
return_value=(Path("/tmp/thumb.jpg"), 720, 1280, 6.023),
553+
) as analyze_video:
554+
with mock.patch.object(client.private, "get", return_value=ok_response):
555+
with mock.patch.object(
556+
client.private,
557+
"post",
558+
side_effect=[ok_response, ok_response],
559+
):
560+
with mock.patch.object(
561+
client, "clip_configure", return_value={"status": "ok"}
562+
) as clip_configure:
563+
with mock.patch(
564+
"builtins.open",
565+
mock.mock_open(read_data=b"video-bytes"),
566+
):
567+
with mock.patch("time.sleep"):
568+
client.clip_upload(
569+
Path("example.mp4"),
570+
"caption",
571+
share_to_facebook=True,
572+
extra_data=extra_data,
573+
)
574+
575+
share_to_fb_extra.assert_called_once()
576+
analyze_video.assert_called_once()
577+
self.assertEqual(extra_data, {"disable_comments": "1"})
578+
configure_extra = clip_configure.call_args.kwargs["extra_data"]
579+
self.assertEqual(configure_extra["disable_comments"], "1")
580+
self.assertEqual(configure_extra["share_to_fb_destination_id"], "fb-destination-id")
581+
self.assertTrue(configure_extra["share_to_facebook_reels"])
582+
self.assertEqual(configure_extra["xpost_surface"], "IG_REELS_COMPOSER")
583+
493584
def test_video_story_upload_raises_clear_error_when_configure_has_no_media(self):
494585
client = self.build_client()
495586

0 commit comments

Comments
 (0)