From 8293b799b87f800e5d73ef82da409e1414fe66ec Mon Sep 17 00:00:00 2001 From: subzeroid <143403577+subzeroid@users.noreply.github.com> Date: Tue, 30 Jun 2026 04:20:36 +0300 Subject: [PATCH 1/9] feat: support linked reels --- docs/usage-guide/media.md | 3 ++ instagrapi/mixins/media.py | 61 ++++++++++++++++++++++++++++++++++ tests/regression/test_media.py | 57 +++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) diff --git a/docs/usage-guide/media.md b/docs/usage-guide/media.md index 5e8b29ec..eca59ab0 100644 --- a/docs/usage-guide/media.md +++ b/docs/usage-guide/media.md @@ -38,6 +38,8 @@ In terms of Instagram, this is called Media, usually users call it publications | media_info(media_pk: str, use_cache: bool = True) | Media | Return media info | | media_delete(media_id: str) | bool | Delete media | | media_edit(media_id: str, caption: str, title: str = "", usertags: List[Usertag] = [], location: Location = None) | dict | Edit caption, optional IGTV title, usertags, or location | +| media_link_reel(media_id: str, target_media_id: str, link_name: str = "Watch Next") | bool | Link one Reel to another Reel so Instagram can show a navigation button | +| media_unlink_reel(media_id: str) | bool | Remove the linked Reel navigation button from media | | media_user(media_pk: str) | UserShort | Get author of media | | media_oembed(url: str) | dict | Return short oEmbed-style media info by URL | | media_like(media_id: str) | bool | Like media | @@ -275,6 +277,7 @@ Notes: * `media_pk_from_url()` now also resolves `share/p/...` URLs before extracting the canonical shortcode. * Accepted Instagram Collabs/coauthor users from private media payloads are available as `media.coauthor_producers`. This is separate from upload-time `coauthor_user_ids`, which only sends collaborator invitations. * `media_edit()` uses `caption` and optional `location`/`usertags`; for IGTV posts it can also derive or send a separate `title`. +* `media_link_reel()` and `media_unlink_reel()` use Instagram's private `media/{media_id}/edit_media/` endpoint. Pass full media IDs when you have them; plain media PKs are normalized with `media_id()` before the request. Extended `Media` metadata fields: diff --git a/instagrapi/mixins/media.py b/instagrapi/mixins/media.py index 2d29c298..ac076f94 100644 --- a/instagrapi/mixins/media.py +++ b/instagrapi/mixins/media.py @@ -787,6 +787,67 @@ def media_edit( ) return result + def media_link_reel(self, media_id: str, target_media_id: str, link_name: str = "Watch Next") -> bool: + """ + Link one Reel to another Reel. + + Parameters + ---------- + media_id: str + Origin media id that receives the linked Reel navigation button + target_media_id: str + Destination media id opened by the linked Reel navigation button + link_name: str, optional + Text shown by Instagram for the link, default value is "Watch Next" + + Returns + ------- + bool + A boolean value + """ + assert self.user_id, "Login required" + media_id = self.media_id(media_id) + target_media_id = self.media_id(target_media_id) + data = { + "_uid": str(self.user_id), + "_uuid": self.uuid, + "linked_media_info": dumps({"media_id": target_media_id, "link_name": link_name}), + } + self._medias_cache.pop(self.media_pk(media_id), None) + result = self.private_request( + f"media/{media_id}/edit_media/", + self.with_action_data(data), + ) + return result.get("status") == "ok" + + def media_unlink_reel(self, media_id: str) -> bool: + """ + Remove a linked Reel from media. + + Parameters + ---------- + media_id: str + Media id that should have its linked Reel cleared + + Returns + ------- + bool + A boolean value + """ + assert self.user_id, "Login required" + media_id = self.media_id(media_id) + data = { + "_uid": str(self.user_id), + "_uuid": self.uuid, + "linked_media_info": "", + } + self._medias_cache.pop(self.media_pk(media_id), None) + result = self.private_request( + f"media/{media_id}/edit_media/", + self.with_action_data(data), + ) + return result.get("status") == "ok" + def media_user(self, media_pk: str) -> UserShort: """ Get author of the media diff --git a/tests/regression/test_media.py b/tests/regression/test_media.py index e69564ae..acdb56ee 100644 --- a/tests/regression/test_media.py +++ b/tests/regression/test_media.py @@ -910,6 +910,63 @@ def test_media_note_delete_posts_current_v2_payload(self): with_signature=False, ) + def test_media_link_reel_posts_linked_media_info(self): + client = self._build_logged_in_client() + client._medias_cache = {"111": object()} + + with mock.patch.object(client, "private_request", return_value={"status": "ok"}) as private_request: + result = client.media_link_reel("111_222", "333_444", link_name="Watch Part 1") + + self.assertTrue(result) + endpoint, data = private_request.call_args.args + self.assertEqual(endpoint, "media/111_222/edit_media/") + self.assertEqual(data["_uid"], "1") + self.assertEqual(data["_uuid"], "uuid") + self.assertEqual(data["device_id"], "android-device") + self.assertEqual(data["radio_type"], "wifi-none") + self.assertEqual( + json.loads(data["linked_media_info"]), + {"media_id": "333_444", "link_name": "Watch Part 1"}, + ) + self.assertNotIn("111", client._medias_cache) + + def test_media_link_reel_normalizes_origin_and_target_media_ids(self): + client = self._build_logged_in_client() + + with ( + mock.patch.object( + client, + "media_user", + side_effect=[ + UserShort(pk="222", username="origin", profile_pic_url="https://example.com/origin.jpg"), + UserShort(pk="444", username="target", profile_pic_url="https://example.com/target.jpg"), + ], + ), + mock.patch.object(client, "private_request", return_value={"status": "ok"}) as private_request, + ): + self.assertTrue(client.media_link_reel("111", "333")) + + endpoint, data = private_request.call_args.args + self.assertEqual(endpoint, "media/111_222/edit_media/") + self.assertEqual(json.loads(data["linked_media_info"])["media_id"], "333_444") + + def test_media_unlink_reel_posts_empty_linked_media_info(self): + client = self._build_logged_in_client() + client._medias_cache = {"111": object()} + + with mock.patch.object(client, "private_request", return_value={"status": "ok"}) as private_request: + result = client.media_unlink_reel("111_222") + + self.assertTrue(result) + endpoint, data = private_request.call_args.args + self.assertEqual(endpoint, "media/111_222/edit_media/") + self.assertEqual(data["_uid"], "1") + self.assertEqual(data["_uuid"], "uuid") + self.assertEqual(data["device_id"], "android-device") + self.assertEqual(data["radio_type"], "wifi-none") + self.assertEqual(data["linked_media_info"], "") + self.assertNotIn("111", client._medias_cache) + class UsertagMediasPaginationRegressionTestCase(unittest.TestCase): def _media_v1_payload(self, pk="1"): From 9b4e01ecfd7d91353b75c7648a7e8bc4a4232cb4 Mon Sep 17 00:00:00 2001 From: subzeroid <143403577+subzeroid@users.noreply.github.com> Date: Tue, 30 Jun 2026 04:44:04 +0300 Subject: [PATCH 2/9] test: cover linked reels live flow --- tests/live/test_media.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/live/test_media.py b/tests/live/test_media.py index 5147d4a3..19b4cfc9 100644 --- a/tests/live/test_media.py +++ b/tests/live/test_media.py @@ -114,6 +114,37 @@ def test_media_edit(self): if media: self.cl.media_delete(media.id) + def test_media_link_and_unlink_reel(self): + origin_path = self.make_video_fixture(label="linked Reel origin fixture", color="blue") + target_path = self.make_video_fixture(label="linked Reel target fixture", color="red") + self.assertIsInstance(origin_path, Path) + self.assertIsInstance(target_path, Path) + origin = None + target = None + try: + timestamp = int(time.time()) + target = self.cl.clip_upload(target_path, f"Linked Reel target {timestamp}") + self.assertUploadedMediaAccessible( + target, + media_type=2, + product_type="clips", + caption_text=f"Linked Reel target {timestamp}", + ) + origin = self.cl.clip_upload(origin_path, f"Linked Reel origin {timestamp}") + self.assertUploadedMediaAccessible( + origin, + media_type=2, + product_type="clips", + caption_text=f"Linked Reel origin {timestamp}", + ) + + self.assertTrue(self.cl.media_link_reel(origin.id, target.id, link_name="Watch Next")) + self.assertTrue(self.cl.media_unlink_reel(origin.id)) + finally: + for media in (origin, target): + if media: + self.cl.media_delete(media.id) + def test_media_edit_igtv(self): path = self.make_video_fixture(label="IGTV edit fixture", duration=61) self.assertIsInstance(path, Path) From 86857131c48c0ab82e531f92bd7b31cd9aa0cd86 Mon Sep 17 00:00:00 2001 From: subzeroid <143403577+subzeroid@users.noreply.github.com> Date: Tue, 30 Jun 2026 04:47:44 +0300 Subject: [PATCH 3/9] test: widen linked reel live account pool --- tests/live/test_media.py | 79 ++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/tests/live/test_media.py b/tests/live/test_media.py index 19b4cfc9..6d5019b0 100644 --- a/tests/live/test_media.py +++ b/tests/live/test_media.py @@ -114,37 +114,6 @@ def test_media_edit(self): if media: self.cl.media_delete(media.id) - def test_media_link_and_unlink_reel(self): - origin_path = self.make_video_fixture(label="linked Reel origin fixture", color="blue") - target_path = self.make_video_fixture(label="linked Reel target fixture", color="red") - self.assertIsInstance(origin_path, Path) - self.assertIsInstance(target_path, Path) - origin = None - target = None - try: - timestamp = int(time.time()) - target = self.cl.clip_upload(target_path, f"Linked Reel target {timestamp}") - self.assertUploadedMediaAccessible( - target, - media_type=2, - product_type="clips", - caption_text=f"Linked Reel target {timestamp}", - ) - origin = self.cl.clip_upload(origin_path, f"Linked Reel origin {timestamp}") - self.assertUploadedMediaAccessible( - origin, - media_type=2, - product_type="clips", - caption_text=f"Linked Reel origin {timestamp}", - ) - - self.assertTrue(self.cl.media_link_reel(origin.id, target.id, link_name="Watch Next")) - self.assertTrue(self.cl.media_unlink_reel(origin.id)) - finally: - for media in (origin, target): - if media: - self.cl.media_delete(media.id) - def test_media_edit_igtv(self): path = self.make_video_fixture(label="IGTV edit fixture", duration=61) self.assertIsInstance(path, Path) @@ -217,6 +186,54 @@ def test_media_note_create_and_delete(self): self.assertTrue(self.cl.media_note_delete(note["id"])) +class ClientLinkedReelLiveTestCase(_helpers.ClientPrivateTestCase): + def __init__(self, *args, **kwargs): + self.cl = None + return unittest.TestCase.__init__(self, *args, **kwargs) + + def setup_method(self, *args, **kwargs): + return None + + def setUp(self): + if not TEST_ACCOUNTS_URL: + self.skipTest("TEST_ACCOUNTS_URL is required for linked Reel live tests") + try: + self.cl = _helpers.fresh_test_account(count=50, attempts=20, timeout=30) + except RuntimeError as exc: + self.skipTest(str(exc)) + + def test_media_link_and_unlink_reel(self): + origin_path = self.make_video_fixture(label="linked Reel origin fixture", color="blue") + target_path = self.make_video_fixture(label="linked Reel target fixture", color="red") + self.assertIsInstance(origin_path, Path) + self.assertIsInstance(target_path, Path) + origin = None + target = None + try: + timestamp = int(time.time()) + target = self.cl.clip_upload(target_path, f"Linked Reel target {timestamp}") + self.assertUploadedMediaAccessible( + target, + media_type=2, + product_type="clips", + caption_text=f"Linked Reel target {timestamp}", + ) + origin = self.cl.clip_upload(origin_path, f"Linked Reel origin {timestamp}") + self.assertUploadedMediaAccessible( + origin, + media_type=2, + product_type="clips", + caption_text=f"Linked Reel origin {timestamp}", + ) + + self.assertTrue(self.cl.media_link_reel(origin.id, target.id, link_name="Watch Next")) + self.assertTrue(self.cl.media_unlink_reel(origin.id)) + finally: + for media in (origin, target): + if media: + self.cl.media_delete(media.id) + + class ClientCompareExtractTestCase(_helpers.ClientPrivateTestCase): def assertLocation(self, v1, gql): if not isinstance(v1, dict): From 380cc5ef8bb3fd84ed974aea87fa3752c6699589 Mon Sep 17 00:00:00 2001 From: subzeroid <143403577+subzeroid@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:02:05 +0300 Subject: [PATCH 4/9] test: reuse authorized live account sessions --- tests/helpers.py | 9 +++++++-- tests/regression/test_helpers.py | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 845eb245..4823a770 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -211,11 +211,16 @@ def build_test_accounts_url(count=None): def client_from_test_account(acc): settings = dict(acc["client_settings"]) totp_seed = settings.pop("totp_seed", None) + authorization_data = settings.get("authorization_data") or {} + account_user_id = acc.get("user_id") or authorization_data.get("ds_user_id") + has_authorized_session = bool(authorization_data) cl = Client(settings=settings, proxy=os.getenv("IG_PROXY") or acc["proxy"]) + if has_authorized_session and account_user_id: + cl._user_id = str(account_user_id) login_kwargs = { "username": acc["username"], "password": acc["password"], - "relogin": True, + "relogin": not has_authorized_session, } if totp_seed: totp_code = cl.totp_generate_code(totp_seed) @@ -224,7 +229,7 @@ def client_from_test_account(acc): login_kwargs["verification_code"] = totp_code with fresh_account_login_timeout(): cl.login(**login_kwargs) - cl._user_id = acc.get("user_id") + cl._user_id = account_user_id return cl diff --git a/tests/regression/test_helpers.py b/tests/regression/test_helpers.py index d27a65a2..4e363616 100644 --- a/tests/regression/test_helpers.py +++ b/tests/regression/test_helpers.py @@ -65,6 +65,40 @@ def test_client_from_test_account_times_out_hung_login(self): with self.assertRaises(helper_module.FreshAccountLoginTimeout): helper_module.client_from_test_account(self._account_payload()) + def test_client_from_test_account_reuses_authorized_session_settings(self): + account = self._account_payload() + account["client_settings"] = { + "authorization_data": { + "ds_user_id": "123", + "sessionid": "sessionid", + "should_use_header_over_cookies": True, + }, + } + client = Mock() + + def assert_user_id_set_before_login(**kwargs): + self.assertEqual(client._user_id, "123") + return True + + client.login.side_effect = assert_user_id_set_before_login + + with mock.patch.dict(helper_module.os.environ, {"IG_PROXY": ""}): + with mock.patch.object(helper_module, "Client", return_value=client): + result = helper_module.client_from_test_account(account) + + self.assertIs(result, client) + client.login.assert_called_once_with(username="fresh.account", password="password", relogin=False) + + def test_client_from_test_account_relogs_without_authorized_session_settings(self): + client = Mock() + + with mock.patch.dict(helper_module.os.environ, {"IG_PROXY": ""}): + with mock.patch.object(helper_module, "Client", return_value=client): + result = helper_module.client_from_test_account(self._account_payload()) + + self.assertIs(result, client) + client.login.assert_called_once_with(username="fresh.account", password="password", relogin=True) + def test_fresh_test_account_tries_next_account_after_login_timeout(self): accounts = [self._account_payload("first.account"), self._account_payload("second.account")] client = object() From c9137251cabe6ac9a19679c292f51e97c3aa681b Mon Sep 17 00:00:00 2001 From: subzeroid <143403577+subzeroid@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:06:20 +0300 Subject: [PATCH 5/9] test: harden linked reel live flow --- tests/live/test_media.py | 74 ++++++++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/tests/live/test_media.py b/tests/live/test_media.py index 6d5019b0..48a073ee 100644 --- a/tests/live/test_media.py +++ b/tests/live/test_media.py @@ -1,4 +1,11 @@ -from instagrapi.exceptions import ClientForbiddenError +from instagrapi.exceptions import ( + ClientForbiddenError, + ClientThrottledError, + ClipNotUpload, + LoginRequired, + PhotoNotUpload, + PleaseWaitFewMinutes, +) from tests import helpers as _helpers from tests.helpers import * @@ -189,6 +196,7 @@ def test_media_note_create_and_delete(self): class ClientLinkedReelLiveTestCase(_helpers.ClientPrivateTestCase): def __init__(self, *args, **kwargs): self.cl = None + self.clients = [] return unittest.TestCase.__init__(self, *args, **kwargs) def setup_method(self, *args, **kwargs): @@ -198,27 +206,44 @@ def setUp(self): if not TEST_ACCOUNTS_URL: self.skipTest("TEST_ACCOUNTS_URL is required for linked Reel live tests") try: - self.cl = _helpers.fresh_test_account(count=50, attempts=20, timeout=30) + self.clients = self.fresh_accounts(10) except RuntimeError as exc: self.skipTest(str(exc)) - def test_media_link_and_unlink_reel(self): - origin_path = self.make_video_fixture(label="linked Reel origin fixture", color="blue") - target_path = self.make_video_fixture(label="linked Reel target fixture", color="red") - self.assertIsInstance(origin_path, Path) - self.assertIsInstance(target_path, Path) + def make_cover_fixture(self, color): + try: + from PIL import Image + except ImportError: + self.skipTest("Pillow is required to generate a linked Reel cover fixture") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: + path = Path(tmp.name) + self.addCleanup(lambda: path.unlink(missing_ok=True)) + Image.new("RGB", (720, 1280), color).save(path, quality=95) + return path + + def cleanup_uploaded_media(self, media): + if not media: + return + try: + self.assertTrue(self.cl.media_delete(media.id)) + except Exception as exc: + print(f"Linked Reel cleanup media_delete failed: {exc.__class__.__name__} {exc}") + + def run_media_link_and_unlink_reel(self, client, origin_path, target_path, origin_cover, target_cover): + self.cl = client origin = None target = None try: timestamp = int(time.time()) - target = self.cl.clip_upload(target_path, f"Linked Reel target {timestamp}") + target = self.cl.clip_upload(target_path, f"Linked Reel target {timestamp}", thumbnail=target_cover) self.assertUploadedMediaAccessible( target, media_type=2, product_type="clips", caption_text=f"Linked Reel target {timestamp}", ) - origin = self.cl.clip_upload(origin_path, f"Linked Reel origin {timestamp}") + origin = self.cl.clip_upload(origin_path, f"Linked Reel origin {timestamp}", thumbnail=origin_cover) self.assertUploadedMediaAccessible( origin, media_type=2, @@ -230,8 +255,35 @@ def test_media_link_and_unlink_reel(self): self.assertTrue(self.cl.media_unlink_reel(origin.id)) finally: for media in (origin, target): - if media: - self.cl.media_delete(media.id) + self.cleanup_uploaded_media(media) + + def test_media_link_and_unlink_reel(self): + origin_path = self.make_video_fixture(label="linked Reel origin fixture", color="blue") + target_path = self.make_video_fixture(label="linked Reel target fixture", color="red") + origin_cover = self.make_cover_fixture("blue") + target_cover = self.make_cover_fixture("red") + self.assertIsInstance(origin_path, Path) + self.assertIsInstance(target_path, Path) + self.assertIsInstance(origin_cover, Path) + self.assertIsInstance(target_cover, Path) + + upload_failures = {} + for client in self.clients: + try: + self.run_media_link_and_unlink_reel(client, origin_path, target_path, origin_cover, target_cover) + return + except ( + ClipNotUpload, + PhotoNotUpload, + LoginRequired, + PleaseWaitFewMinutes, + ClientThrottledError, + RetryError, + ) as exc: + upload_failures[exc.__class__.__name__] = upload_failures.get(exc.__class__.__name__, 0) + 1 + continue + + self.skipTest(f"No linked Reel upload-capable test account was available (upload_failures={upload_failures})") class ClientCompareExtractTestCase(_helpers.ClientPrivateTestCase): From ca5d2103b8cbfea801387ef1deb1c4024b304a2a Mon Sep 17 00:00:00 2001 From: subzeroid <143403577+subzeroid@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:09:30 +0300 Subject: [PATCH 6/9] fix: send json payload when unlinking reels --- instagrapi/mixins/media.py | 2 +- tests/regression/test_media.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/instagrapi/mixins/media.py b/instagrapi/mixins/media.py index ac076f94..d93f7678 100644 --- a/instagrapi/mixins/media.py +++ b/instagrapi/mixins/media.py @@ -839,7 +839,7 @@ def media_unlink_reel(self, media_id: str) -> bool: data = { "_uid": str(self.user_id), "_uuid": self.uuid, - "linked_media_info": "", + "linked_media_info": dumps({}), } self._medias_cache.pop(self.media_pk(media_id), None) result = self.private_request( diff --git a/tests/regression/test_media.py b/tests/regression/test_media.py index acdb56ee..5f72f6b7 100644 --- a/tests/regression/test_media.py +++ b/tests/regression/test_media.py @@ -950,7 +950,7 @@ def test_media_link_reel_normalizes_origin_and_target_media_ids(self): self.assertEqual(endpoint, "media/111_222/edit_media/") self.assertEqual(json.loads(data["linked_media_info"])["media_id"], "333_444") - def test_media_unlink_reel_posts_empty_linked_media_info(self): + def test_media_unlink_reel_posts_empty_linked_media_info_object(self): client = self._build_logged_in_client() client._medias_cache = {"111": object()} @@ -964,7 +964,7 @@ def test_media_unlink_reel_posts_empty_linked_media_info(self): self.assertEqual(data["_uuid"], "uuid") self.assertEqual(data["device_id"], "android-device") self.assertEqual(data["radio_type"], "wifi-none") - self.assertEqual(data["linked_media_info"], "") + self.assertEqual(json.loads(data["linked_media_info"]), {}) self.assertNotIn("111", client._medias_cache) From 25f32431d0d17e50a8f4757088526d385c5e2304 Mon Sep 17 00:00:00 2001 From: subzeroid <143403577+subzeroid@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:14:46 +0300 Subject: [PATCH 7/9] fix: keep linked reel API to verified link flow --- docs/usage-guide/media.md | 3 +-- instagrapi/mixins/media.py | 28 ---------------------------- tests/live/test_media.py | 7 +++---- tests/regression/test_media.py | 17 ----------------- 4 files changed, 4 insertions(+), 51 deletions(-) diff --git a/docs/usage-guide/media.md b/docs/usage-guide/media.md index eca59ab0..b6cc6e18 100644 --- a/docs/usage-guide/media.md +++ b/docs/usage-guide/media.md @@ -39,7 +39,6 @@ In terms of Instagram, this is called Media, usually users call it publications | media_delete(media_id: str) | bool | Delete media | | media_edit(media_id: str, caption: str, title: str = "", usertags: List[Usertag] = [], location: Location = None) | dict | Edit caption, optional IGTV title, usertags, or location | | media_link_reel(media_id: str, target_media_id: str, link_name: str = "Watch Next") | bool | Link one Reel to another Reel so Instagram can show a navigation button | -| media_unlink_reel(media_id: str) | bool | Remove the linked Reel navigation button from media | | media_user(media_pk: str) | UserShort | Get author of media | | media_oembed(url: str) | dict | Return short oEmbed-style media info by URL | | media_like(media_id: str) | bool | Like media | @@ -277,7 +276,7 @@ Notes: * `media_pk_from_url()` now also resolves `share/p/...` URLs before extracting the canonical shortcode. * Accepted Instagram Collabs/coauthor users from private media payloads are available as `media.coauthor_producers`. This is separate from upload-time `coauthor_user_ids`, which only sends collaborator invitations. * `media_edit()` uses `caption` and optional `location`/`usertags`; for IGTV posts it can also derive or send a separate `title`. -* `media_link_reel()` and `media_unlink_reel()` use Instagram's private `media/{media_id}/edit_media/` endpoint. Pass full media IDs when you have them; plain media PKs are normalized with `media_id()` before the request. +* `media_link_reel()` uses Instagram's private `media/{media_id}/edit_media/` endpoint. Pass full media IDs when you have them; plain media PKs are normalized with `media_id()` before the request. Extended `Media` metadata fields: diff --git a/instagrapi/mixins/media.py b/instagrapi/mixins/media.py index d93f7678..eef18f2d 100644 --- a/instagrapi/mixins/media.py +++ b/instagrapi/mixins/media.py @@ -820,34 +820,6 @@ def media_link_reel(self, media_id: str, target_media_id: str, link_name: str = ) return result.get("status") == "ok" - def media_unlink_reel(self, media_id: str) -> bool: - """ - Remove a linked Reel from media. - - Parameters - ---------- - media_id: str - Media id that should have its linked Reel cleared - - Returns - ------- - bool - A boolean value - """ - assert self.user_id, "Login required" - media_id = self.media_id(media_id) - data = { - "_uid": str(self.user_id), - "_uuid": self.uuid, - "linked_media_info": dumps({}), - } - self._medias_cache.pop(self.media_pk(media_id), None) - result = self.private_request( - f"media/{media_id}/edit_media/", - self.with_action_data(data), - ) - return result.get("status") == "ok" - def media_user(self, media_pk: str) -> UserShort: """ Get author of the media diff --git a/tests/live/test_media.py b/tests/live/test_media.py index 48a073ee..69b3154b 100644 --- a/tests/live/test_media.py +++ b/tests/live/test_media.py @@ -230,7 +230,7 @@ def cleanup_uploaded_media(self, media): except Exception as exc: print(f"Linked Reel cleanup media_delete failed: {exc.__class__.__name__} {exc}") - def run_media_link_and_unlink_reel(self, client, origin_path, target_path, origin_cover, target_cover): + def run_media_link_reel(self, client, origin_path, target_path, origin_cover, target_cover): self.cl = client origin = None target = None @@ -252,12 +252,11 @@ def run_media_link_and_unlink_reel(self, client, origin_path, target_path, origi ) self.assertTrue(self.cl.media_link_reel(origin.id, target.id, link_name="Watch Next")) - self.assertTrue(self.cl.media_unlink_reel(origin.id)) finally: for media in (origin, target): self.cleanup_uploaded_media(media) - def test_media_link_and_unlink_reel(self): + def test_media_link_reel(self): origin_path = self.make_video_fixture(label="linked Reel origin fixture", color="blue") target_path = self.make_video_fixture(label="linked Reel target fixture", color="red") origin_cover = self.make_cover_fixture("blue") @@ -270,7 +269,7 @@ def test_media_link_and_unlink_reel(self): upload_failures = {} for client in self.clients: try: - self.run_media_link_and_unlink_reel(client, origin_path, target_path, origin_cover, target_cover) + self.run_media_link_reel(client, origin_path, target_path, origin_cover, target_cover) return except ( ClipNotUpload, diff --git a/tests/regression/test_media.py b/tests/regression/test_media.py index 5f72f6b7..aab6784c 100644 --- a/tests/regression/test_media.py +++ b/tests/regression/test_media.py @@ -950,23 +950,6 @@ def test_media_link_reel_normalizes_origin_and_target_media_ids(self): self.assertEqual(endpoint, "media/111_222/edit_media/") self.assertEqual(json.loads(data["linked_media_info"])["media_id"], "333_444") - def test_media_unlink_reel_posts_empty_linked_media_info_object(self): - client = self._build_logged_in_client() - client._medias_cache = {"111": object()} - - with mock.patch.object(client, "private_request", return_value={"status": "ok"}) as private_request: - result = client.media_unlink_reel("111_222") - - self.assertTrue(result) - endpoint, data = private_request.call_args.args - self.assertEqual(endpoint, "media/111_222/edit_media/") - self.assertEqual(data["_uid"], "1") - self.assertEqual(data["_uuid"], "uuid") - self.assertEqual(data["device_id"], "android-device") - self.assertEqual(data["radio_type"], "wifi-none") - self.assertEqual(json.loads(data["linked_media_info"]), {}) - self.assertNotIn("111", client._medias_cache) - class UsertagMediasPaginationRegressionTestCase(unittest.TestCase): def _media_v1_payload(self, pk="1"): From 72535743db75fa5907c29de95ab5a0b0216e05c2 Mon Sep 17 00:00:00 2001 From: subzeroid <143403577+subzeroid@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:46:08 +0300 Subject: [PATCH 8/9] feat: add linked reel unlink mutation --- docs/usage-guide/media.md | 3 ++- instagrapi/mixins/media.py | 41 ++++++++++++++++++++++++++++++ tests/live/test_media.py | 20 ++++++++++++--- tests/regression/test_media.py | 46 ++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/docs/usage-guide/media.md b/docs/usage-guide/media.md index b6cc6e18..28cf221a 100644 --- a/docs/usage-guide/media.md +++ b/docs/usage-guide/media.md @@ -39,6 +39,7 @@ In terms of Instagram, this is called Media, usually users call it publications | media_delete(media_id: str) | bool | Delete media | | media_edit(media_id: str, caption: str, title: str = "", usertags: List[Usertag] = [], location: Location = None) | dict | Edit caption, optional IGTV title, usertags, or location | | media_link_reel(media_id: str, target_media_id: str, link_name: str = "Watch Next") | bool | Link one Reel to another Reel so Instagram can show a navigation button | +| media_unlink_reel(media_id: str, target_media_id: str = None) | bool | Remove a linked Reel navigation button from a Reel | | media_user(media_pk: str) | UserShort | Get author of media | | media_oembed(url: str) | dict | Return short oEmbed-style media info by URL | | media_like(media_id: str) | bool | Like media | @@ -276,7 +277,7 @@ Notes: * `media_pk_from_url()` now also resolves `share/p/...` URLs before extracting the canonical shortcode. * Accepted Instagram Collabs/coauthor users from private media payloads are available as `media.coauthor_producers`. This is separate from upload-time `coauthor_user_ids`, which only sends collaborator invitations. * `media_edit()` uses `caption` and optional `location`/`usertags`; for IGTV posts it can also derive or send a separate `title`. -* `media_link_reel()` uses Instagram's private `media/{media_id}/edit_media/` endpoint. Pass full media IDs when you have them; plain media PKs are normalized with `media_id()` before the request. +* `media_link_reel()` uses Instagram's private `media/{media_id}/edit_media/` endpoint. `media_unlink_reel()` uses Instagram's private `LinkedMediaDelete` GraphQL mutation. Pass full media IDs when you have them; plain media PKs are normalized with `media_id()` before the request. Extended `Media` metadata fields: diff --git a/instagrapi/mixins/media.py b/instagrapi/mixins/media.py index eef18f2d..3259add6 100644 --- a/instagrapi/mixins/media.py +++ b/instagrapi/mixins/media.py @@ -35,6 +35,9 @@ MEDIA_INFO_DOC_ID = "27128499623469141" IG_PROFILE_TIMELINE_DOC_ID = "56030350814417327502004290437" +LINKED_MEDIA_DELETE_CLIENT_DOC_ID = "8731250647292294617344433924" +LINKED_MEDIA_DELETE_FRIENDLY_NAME = "LinkedMediaDelete" +LINKED_MEDIA_DELETE_ROOT_FIELD = "xig_linked_media_delete" class MediaMixin: @@ -820,6 +823,44 @@ def media_link_reel(self, media_id: str, target_media_id: str, link_name: str = ) return result.get("status") == "ok" + def media_unlink_reel(self, media_id: str, target_media_id: Optional[str] = None) -> bool: + """ + Remove a linked Reel navigation button from a Reel. + + Parameters + ---------- + media_id: str + Origin media id that currently has the linked Reel navigation button + target_media_id: str, optional + Destination media id to remove from the origin media. When omitted, + Instagram removes the linked Reel from the origin media. + + Returns + ------- + bool + A boolean value + """ + assert self.user_id, "Login required" + media_id = self.media_id(media_id) + input_data = {"source_media_id": media_id} + if target_media_id is not None: + input_data["destination_media_id"] = self.media_id(target_media_id) + self._medias_cache.pop(self.media_pk(media_id), None) + result = self.private_graphql_query_request( + friendly_name=LINKED_MEDIA_DELETE_FRIENDLY_NAME, + root_field_name=LINKED_MEDIA_DELETE_ROOT_FIELD, + variables={"input_data": input_data}, + client_doc_id=LINKED_MEDIA_DELETE_CLIENT_DOC_ID, + ) + if result.get("status") == "ok": + return True + root = (result.get("data") or {}).get(LINKED_MEDIA_DELETE_ROOT_FIELD) + if isinstance(root, dict): + if root.get("status") is not None: + return root.get("status") == "ok" + return root.get("success") is True or root.get("is_success") is True + return root is True + def media_user(self, media_pk: str) -> UserShort: """ Get author of the media diff --git a/tests/live/test_media.py b/tests/live/test_media.py index 69b3154b..164376ef 100644 --- a/tests/live/test_media.py +++ b/tests/live/test_media.py @@ -230,7 +230,17 @@ def cleanup_uploaded_media(self, media): except Exception as exc: print(f"Linked Reel cleanup media_delete failed: {exc.__class__.__name__} {exc}") - def run_media_link_reel(self, client, origin_path, target_path, origin_cover, target_cover): + def assert_linked_media_data_removed(self, media, attempts=6, delay=2): + last_payload = None + for attempt in range(attempts): + if attempt: + time.sleep(delay) + last_payload = self.uploaded_media_payload(media) + if not last_payload.get("linked_media_data"): + return + self.fail(f"linked_media_data was still present after unlink: {last_payload.get('linked_media_data')}") + + def run_media_link_and_unlink_reel(self, client, origin_path, target_path, origin_cover, target_cover): self.cl = client origin = None target = None @@ -252,11 +262,15 @@ def run_media_link_reel(self, client, origin_path, target_path, origin_cover, ta ) self.assertTrue(self.cl.media_link_reel(origin.id, target.id, link_name="Watch Next")) + linked_payload = self.uploaded_media_payload(origin) + self.assertTrue(linked_payload.get("linked_media_data")) + self.assertTrue(self.cl.media_unlink_reel(origin.id, target_media_id=target.id)) + self.assert_linked_media_data_removed(origin) finally: for media in (origin, target): self.cleanup_uploaded_media(media) - def test_media_link_reel(self): + def test_media_link_and_unlink_reel(self): origin_path = self.make_video_fixture(label="linked Reel origin fixture", color="blue") target_path = self.make_video_fixture(label="linked Reel target fixture", color="red") origin_cover = self.make_cover_fixture("blue") @@ -269,7 +283,7 @@ def test_media_link_reel(self): upload_failures = {} for client in self.clients: try: - self.run_media_link_reel(client, origin_path, target_path, origin_cover, target_cover) + self.run_media_link_and_unlink_reel(client, origin_path, target_path, origin_cover, target_cover) return except ( ClipNotUpload, diff --git a/tests/regression/test_media.py b/tests/regression/test_media.py index aab6784c..fa70745a 100644 --- a/tests/regression/test_media.py +++ b/tests/regression/test_media.py @@ -950,6 +950,52 @@ def test_media_link_reel_normalizes_origin_and_target_media_ids(self): self.assertEqual(endpoint, "media/111_222/edit_media/") self.assertEqual(json.loads(data["linked_media_info"])["media_id"], "333_444") + def test_media_unlink_reel_posts_linked_media_delete_graphql_mutation(self): + client = self._build_logged_in_client() + client._medias_cache = {"111": object()} + + with mock.patch.object( + client, + "private_graphql_query_request", + return_value={"data": {"xig_linked_media_delete": {"status": "ok"}}}, + ) as graphql_query: + result = client.media_unlink_reel("111_222") + + self.assertTrue(result) + graphql_query.assert_called_once_with( + friendly_name="LinkedMediaDelete", + root_field_name="xig_linked_media_delete", + variables={"input_data": {"source_media_id": "111_222"}}, + client_doc_id="8731250647292294617344433924", + ) + self.assertNotIn("111", client._medias_cache) + + def test_media_unlink_reel_normalizes_optional_target_media_id(self): + client = self._build_logged_in_client() + + with ( + mock.patch.object( + client, + "media_user", + side_effect=[ + UserShort(pk="222", username="origin", profile_pic_url="https://example.com/origin.jpg"), + UserShort(pk="444", username="target", profile_pic_url="https://example.com/target.jpg"), + ], + ), + mock.patch.object( + client, + "private_graphql_query_request", + return_value={"data": {"xig_linked_media_delete": {"status": "ok"}}}, + ) as graphql_query, + ): + self.assertTrue(client.media_unlink_reel("111", target_media_id="333")) + + variables = graphql_query.call_args.kwargs["variables"] + self.assertEqual( + variables, + {"input_data": {"source_media_id": "111_222", "destination_media_id": "333_444"}}, + ) + class UsertagMediasPaginationRegressionTestCase(unittest.TestCase): def _media_v1_payload(self, pk="1"): From 34d630c0adb8cb0fe61466c32e7615deb114072d Mon Sep 17 00:00:00 2001 From: subzeroid <143403577+subzeroid@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:55:53 +0300 Subject: [PATCH 9/9] Revert "feat: add linked reel unlink mutation" This reverts commit 72535743db75fa5907c29de95ab5a0b0216e05c2. --- docs/usage-guide/media.md | 3 +-- instagrapi/mixins/media.py | 41 ------------------------------ tests/live/test_media.py | 20 +++------------ tests/regression/test_media.py | 46 ---------------------------------- 4 files changed, 4 insertions(+), 106 deletions(-) diff --git a/docs/usage-guide/media.md b/docs/usage-guide/media.md index 28cf221a..b6cc6e18 100644 --- a/docs/usage-guide/media.md +++ b/docs/usage-guide/media.md @@ -39,7 +39,6 @@ In terms of Instagram, this is called Media, usually users call it publications | media_delete(media_id: str) | bool | Delete media | | media_edit(media_id: str, caption: str, title: str = "", usertags: List[Usertag] = [], location: Location = None) | dict | Edit caption, optional IGTV title, usertags, or location | | media_link_reel(media_id: str, target_media_id: str, link_name: str = "Watch Next") | bool | Link one Reel to another Reel so Instagram can show a navigation button | -| media_unlink_reel(media_id: str, target_media_id: str = None) | bool | Remove a linked Reel navigation button from a Reel | | media_user(media_pk: str) | UserShort | Get author of media | | media_oembed(url: str) | dict | Return short oEmbed-style media info by URL | | media_like(media_id: str) | bool | Like media | @@ -277,7 +276,7 @@ Notes: * `media_pk_from_url()` now also resolves `share/p/...` URLs before extracting the canonical shortcode. * Accepted Instagram Collabs/coauthor users from private media payloads are available as `media.coauthor_producers`. This is separate from upload-time `coauthor_user_ids`, which only sends collaborator invitations. * `media_edit()` uses `caption` and optional `location`/`usertags`; for IGTV posts it can also derive or send a separate `title`. -* `media_link_reel()` uses Instagram's private `media/{media_id}/edit_media/` endpoint. `media_unlink_reel()` uses Instagram's private `LinkedMediaDelete` GraphQL mutation. Pass full media IDs when you have them; plain media PKs are normalized with `media_id()` before the request. +* `media_link_reel()` uses Instagram's private `media/{media_id}/edit_media/` endpoint. Pass full media IDs when you have them; plain media PKs are normalized with `media_id()` before the request. Extended `Media` metadata fields: diff --git a/instagrapi/mixins/media.py b/instagrapi/mixins/media.py index 3259add6..eef18f2d 100644 --- a/instagrapi/mixins/media.py +++ b/instagrapi/mixins/media.py @@ -35,9 +35,6 @@ MEDIA_INFO_DOC_ID = "27128499623469141" IG_PROFILE_TIMELINE_DOC_ID = "56030350814417327502004290437" -LINKED_MEDIA_DELETE_CLIENT_DOC_ID = "8731250647292294617344433924" -LINKED_MEDIA_DELETE_FRIENDLY_NAME = "LinkedMediaDelete" -LINKED_MEDIA_DELETE_ROOT_FIELD = "xig_linked_media_delete" class MediaMixin: @@ -823,44 +820,6 @@ def media_link_reel(self, media_id: str, target_media_id: str, link_name: str = ) return result.get("status") == "ok" - def media_unlink_reel(self, media_id: str, target_media_id: Optional[str] = None) -> bool: - """ - Remove a linked Reel navigation button from a Reel. - - Parameters - ---------- - media_id: str - Origin media id that currently has the linked Reel navigation button - target_media_id: str, optional - Destination media id to remove from the origin media. When omitted, - Instagram removes the linked Reel from the origin media. - - Returns - ------- - bool - A boolean value - """ - assert self.user_id, "Login required" - media_id = self.media_id(media_id) - input_data = {"source_media_id": media_id} - if target_media_id is not None: - input_data["destination_media_id"] = self.media_id(target_media_id) - self._medias_cache.pop(self.media_pk(media_id), None) - result = self.private_graphql_query_request( - friendly_name=LINKED_MEDIA_DELETE_FRIENDLY_NAME, - root_field_name=LINKED_MEDIA_DELETE_ROOT_FIELD, - variables={"input_data": input_data}, - client_doc_id=LINKED_MEDIA_DELETE_CLIENT_DOC_ID, - ) - if result.get("status") == "ok": - return True - root = (result.get("data") or {}).get(LINKED_MEDIA_DELETE_ROOT_FIELD) - if isinstance(root, dict): - if root.get("status") is not None: - return root.get("status") == "ok" - return root.get("success") is True or root.get("is_success") is True - return root is True - def media_user(self, media_pk: str) -> UserShort: """ Get author of the media diff --git a/tests/live/test_media.py b/tests/live/test_media.py index 164376ef..69b3154b 100644 --- a/tests/live/test_media.py +++ b/tests/live/test_media.py @@ -230,17 +230,7 @@ def cleanup_uploaded_media(self, media): except Exception as exc: print(f"Linked Reel cleanup media_delete failed: {exc.__class__.__name__} {exc}") - def assert_linked_media_data_removed(self, media, attempts=6, delay=2): - last_payload = None - for attempt in range(attempts): - if attempt: - time.sleep(delay) - last_payload = self.uploaded_media_payload(media) - if not last_payload.get("linked_media_data"): - return - self.fail(f"linked_media_data was still present after unlink: {last_payload.get('linked_media_data')}") - - def run_media_link_and_unlink_reel(self, client, origin_path, target_path, origin_cover, target_cover): + def run_media_link_reel(self, client, origin_path, target_path, origin_cover, target_cover): self.cl = client origin = None target = None @@ -262,15 +252,11 @@ def run_media_link_and_unlink_reel(self, client, origin_path, target_path, origi ) self.assertTrue(self.cl.media_link_reel(origin.id, target.id, link_name="Watch Next")) - linked_payload = self.uploaded_media_payload(origin) - self.assertTrue(linked_payload.get("linked_media_data")) - self.assertTrue(self.cl.media_unlink_reel(origin.id, target_media_id=target.id)) - self.assert_linked_media_data_removed(origin) finally: for media in (origin, target): self.cleanup_uploaded_media(media) - def test_media_link_and_unlink_reel(self): + def test_media_link_reel(self): origin_path = self.make_video_fixture(label="linked Reel origin fixture", color="blue") target_path = self.make_video_fixture(label="linked Reel target fixture", color="red") origin_cover = self.make_cover_fixture("blue") @@ -283,7 +269,7 @@ def test_media_link_and_unlink_reel(self): upload_failures = {} for client in self.clients: try: - self.run_media_link_and_unlink_reel(client, origin_path, target_path, origin_cover, target_cover) + self.run_media_link_reel(client, origin_path, target_path, origin_cover, target_cover) return except ( ClipNotUpload, diff --git a/tests/regression/test_media.py b/tests/regression/test_media.py index fa70745a..aab6784c 100644 --- a/tests/regression/test_media.py +++ b/tests/regression/test_media.py @@ -950,52 +950,6 @@ def test_media_link_reel_normalizes_origin_and_target_media_ids(self): self.assertEqual(endpoint, "media/111_222/edit_media/") self.assertEqual(json.loads(data["linked_media_info"])["media_id"], "333_444") - def test_media_unlink_reel_posts_linked_media_delete_graphql_mutation(self): - client = self._build_logged_in_client() - client._medias_cache = {"111": object()} - - with mock.patch.object( - client, - "private_graphql_query_request", - return_value={"data": {"xig_linked_media_delete": {"status": "ok"}}}, - ) as graphql_query: - result = client.media_unlink_reel("111_222") - - self.assertTrue(result) - graphql_query.assert_called_once_with( - friendly_name="LinkedMediaDelete", - root_field_name="xig_linked_media_delete", - variables={"input_data": {"source_media_id": "111_222"}}, - client_doc_id="8731250647292294617344433924", - ) - self.assertNotIn("111", client._medias_cache) - - def test_media_unlink_reel_normalizes_optional_target_media_id(self): - client = self._build_logged_in_client() - - with ( - mock.patch.object( - client, - "media_user", - side_effect=[ - UserShort(pk="222", username="origin", profile_pic_url="https://example.com/origin.jpg"), - UserShort(pk="444", username="target", profile_pic_url="https://example.com/target.jpg"), - ], - ), - mock.patch.object( - client, - "private_graphql_query_request", - return_value={"data": {"xig_linked_media_delete": {"status": "ok"}}}, - ) as graphql_query, - ): - self.assertTrue(client.media_unlink_reel("111", target_media_id="333")) - - variables = graphql_query.call_args.kwargs["variables"] - self.assertEqual( - variables, - {"input_data": {"source_media_id": "111_222", "destination_media_id": "333_444"}}, - ) - class UsertagMediasPaginationRegressionTestCase(unittest.TestCase): def _media_v1_payload(self, pk="1"):