Browse Source

MakerWorld: download link from the instance endpoint

- Since 2026-11-03 the iot-service profile endpoint answers "success"
  with an empty url, so every import failed with "MakerWorld did not
  return a download URL".
- get_download now asks the design-service instance endpoint on
  api.bambulab.com (the instance behind the profile, same bearer) and
  the profile endpoint only when that gives no link. A refusal from
  the instance is reported as is.
- One request/status helper for both endpoints; sign-out on an
  expired token unchanged.
maziggy 3 days ago
parent
commit
f80d8e9771

+ 1 - 0
CHANGELOG.md

@@ -219,6 +219,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **MakerWorld imports work again (#3447 reported by @waynem-bris, #3448 reported by @frederic3114)** — Since 3 November every MakerWorld import, Save and Save & Slice failed with "MakerWorld did not return a download URL": the Bambu Lab endpoint Bambuddy asked for the download link still answers "success", but with an empty link. The link now comes from MakerWorld's download endpoint for the plate (its instance), with the same Bambu Cloud sign-in; the old endpoint is still asked when that gives none.
 - **"Force color match" is kept on a job for a specific printer (#3445, reported by @frnzzle)** — With a specific printer picked, the print dialog showed the "Force color match" checkbox under each slot but never saved it: the job was added or saved with its trays only, and the box came back unticked. Once the tray picked for a slot no longer fit when the job came up, the scheduler matched the trays again against the colour the file was sliced with and, without that colour loaded, took any spool of the type, with "prefer lowest" the emptiest. A part sliced in white and set to black printed in green. A ticked slot is now saved, so the box stays ticked: set to a tray you picked, it asks for that tray's filament; on a tray the dialog matched by itself, for the file's own, so the job waits for it. Matching again takes only that filament or holds the job. Jobs for "Any" model were not affected.
 - **"Force color match" is kept on a job for a specific printer (#3445, reported by @frnzzle)** — With a specific printer picked, the print dialog showed the "Force color match" checkbox under each slot but never saved it: the job was added or saved with its trays only, and the box came back unticked. Once the tray picked for a slot no longer fit when the job came up, the scheduler matched the trays again against the colour the file was sliced with and, without that colour loaded, took any spool of the type, with "prefer lowest" the emptiest. A part sliced in white and set to black printed in green. A ticked slot is now saved, so the box stays ticked: set to a tray you picked, it asks for that tray's filament; on a tray the dialog matched by itself, for the file's own, so the job waits for it. Matching again takes only that filament or holds the job. Jobs for "Any" model were not affected.
 - **Configure accepts a setting id as the filament (#3444, reported by @Novastorm4332)** — A client of the REST API that configured a slot with a spool's slicer filament as `tray_info_idx` sent a Bambu setting id such as `GFSL99` to the printer, which knows only filament ids (`GFL99`): the slot came back without a colour or type and auto-unlink dropped the spool. Assigning the spool already converted it. Configure now converts it the same way, the setting id staying as the slot's setting id, and does the same for a K profile's filament id. Support and SUNLU ids, which also begin with GFS, are filament ids and stay as they are. Bambuddy's own Configure dialog was not affected.
 - **Configure accepts a setting id as the filament (#3444, reported by @Novastorm4332)** — A client of the REST API that configured a slot with a spool's slicer filament as `tray_info_idx` sent a Bambu setting id such as `GFSL99` to the printer, which knows only filament ids (`GFL99`): the slot came back without a colour or type and auto-unlink dropped the spool. Assigning the spool already converted it. Configure now converts it the same way, the setting id staying as the slot's setting id, and does the same for a K profile's filament id. Support and SUNLU ids, which also begin with GFS, are filament ids and stay as they are. Bambuddy's own Configure dialog was not affected.
 - **A project saved as .gcode.3mf is treated as a project (#3435, reported by @Mounting9612)** — Bambu Studio saves an unsliced project under whatever name it is given, and one called `Foo.gcode.3mf` went into the File Manager as a printable file. Printing it to a cost centre was refused with "Estimated cost is required for cost center prints", which says nothing about the cause; without billing it would have gone to the printer. A `.gcode.3mf` that holds no G-code is now stored as a project and offered for slicing, files already in the library are re-checked once on the update, and the queue refuses such a file with "… isn't sliced yet. Slice it first, then print the sliced file." The File Manager's Add to Queue judges a file by what it holds too, so a sliced plate exported as a plain `.3mf` can be queued from there.
 - **A project saved as .gcode.3mf is treated as a project (#3435, reported by @Mounting9612)** — Bambu Studio saves an unsliced project under whatever name it is given, and one called `Foo.gcode.3mf` went into the File Manager as a printable file. Printing it to a cost centre was refused with "Estimated cost is required for cost center prints", which says nothing about the cause; without billing it would have gone to the printer. A `.gcode.3mf` that holds no G-code is now stored as a project and offered for slicing, files already in the library are re-checked once on the update, and the queue refuses such a file with "… isn't sliced yet. Slice it first, then print the sliced file." The File Manager's Add to Queue judges a file by what it holds too, so a sliced plate exported as a plain `.3mf` can be queued from there.

+ 5 - 5
backend/app/api/routes/makerworld.py

@@ -374,11 +374,11 @@ async def import_instance(
 
 
     service = await _build_service(db, provider, current_user, api_key_cloud_owner)
     service = await _build_service(db, provider, current_user, api_key_cloud_owner)
 
 
-    # YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
-    # (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N} —
-    # resolving that, plus picking a default profile when the frontend didn't
-    # specify one, lives inside ``get_download``. The route only orchestrates
-    # dedupe + persistence so every provider shares those concerns here.
+    # Finding the download link (the instance behind the profile, or the
+    # profile endpoint with the alphanumeric modelId as a fallback, #3447),
+    # plus picking a default profile when the frontend didn't specify one,
+    # lives inside ``get_download``. The route only orchestrates dedupe +
+    # persistence so every provider shares those concerns here.
     ref = ProviderResourceRef(
     ref = ProviderResourceRef(
         source_type=provider.source_type,
         source_type=provider.source_type,
         external_id=str(body.model_id),
         external_id=str(body.model_id),

+ 83 - 22
backend/app/services/model_providers/makerworld/service.py

@@ -235,20 +235,22 @@ class MakerWorldService(ProviderService):
     async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
     async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
         """Resolve the signed 3MF download for a specific MakerWorld profile.
         """Resolve the signed 3MF download for a specific MakerWorld profile.
 
 
-        Handles the provider-specific dance: the iot-service endpoint needs
-        the *alphanumeric* ``modelId`` (e.g. ``"US2bb73b106683e5"``) from the
-        design, not the integer design id, and picks a default profile when
-        the caller didn't specify one. Enriches ``ref.sub_id`` with the actual
-        profile used so the route can build the per-plate dedupe key.
+        Picks a default profile when the caller didn't specify one, and
+        enriches ``ref.sub_id`` with the actual profile used so the route can
+        build the per-plate dedupe key.
+
+        The link comes from the design-service instance endpoint, which takes
+        the *instance* behind the profile (#3447, #3448): since 2026-11-03 the
+        iot-service profile endpoint answers "success" with an empty ``url``.
+        That one is still asked when the instance gives no link, or when no
+        instance is found for the profile; it needs the *alphanumeric*
+        ``modelId`` (e.g. ``"US2bb73b106683e5"``), not the integer design id.
         """
         """
         model_id = int(ref.external_id)
         model_id = int(ref.external_id)
         design = await self.get_design(model_id)
         design = await self.get_design(model_id)
 
 
-        alphanumeric_model_id = design.get("modelId")
-        if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
-            raise MakerWorldUnavailableError("MakerWorld design metadata missing the modelId field")
-
         profile_id = int(ref.sub_id) if ref.sub_id else None
         profile_id = int(ref.sub_id) if ref.sub_id else None
+        envelope: dict[str, Any] | None = None
         if profile_id is None:
         if profile_id is None:
             for instance in design.get("instances") or []:
             for instance in design.get("instances") or []:
                 pid = instance.get("profileId")
                 pid = instance.get("profileId")
@@ -265,9 +267,17 @@ class MakerWorldService(ProviderService):
             if profile_id is None:
             if profile_id is None:
                 raise MakerWorldUnavailableError("MakerWorld returned no instances for this model")
                 raise MakerWorldUnavailableError("MakerWorld returned no instances for this model")
 
 
-        manifest = await self.get_profile_download(profile_id, alphanumeric_model_id)
-
+        manifest: dict[str, Any] = {}
+        instance_id = await self._instance_id_for_profile(model_id, design, profile_id, envelope)
+        if instance_id is not None:
+            manifest = await self.get_instance_download(instance_id)
         signed_url = manifest.get("url")
         signed_url = manifest.get("url")
+        if not signed_url or not isinstance(signed_url, str):
+            alphanumeric_model_id = design.get("modelId")
+            if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
+                raise MakerWorldUnavailableError("MakerWorld design metadata missing the modelId field")
+            manifest = await self.get_profile_download(profile_id, alphanumeric_model_id)
+            signed_url = manifest.get("url")
         if not signed_url or not isinstance(signed_url, str):
         if not signed_url or not isinstance(signed_url, str):
             raise MakerWorldUnavailableError("MakerWorld did not return a download URL")
             raise MakerWorldUnavailableError("MakerWorld did not return a download URL")
 
 
@@ -429,15 +439,66 @@ class MakerWorldService(ProviderService):
         ``api.bambulab.com`` backend (not Cloudflare-protected
         ``api.bambulab.com`` backend (not Cloudflare-protected
         ``makerworld.com``), accepts the same long-lived bearer users already
         ``makerworld.com``), accepts the same long-lived bearer users already
         sign in with, and mints the signed CDN URL that the browser would
         sign in with, and mints the signed CDN URL that the browser would
-        otherwise fetch via session cookies. This is the only known non-
-        cookie path to a download URL, after ruling out ``/design-service/``
-        endpoints on ``makerworld.com`` (cookie-gated) and the now-dead
-        ``/instance/{id}/f3mf?type=download`` shape.
+        otherwise fetch via session cookies. The ``/design-service/``
+        endpoints on ``makerworld.com`` are cookie-gated; the instance one on
+        ``api.bambulab.com`` is the other non-cookie path.
+
+        Since 2026-11-03 it answers "success" with an empty ``url`` (#3447,
+        #3448); ``get_download`` asks ``get_instance_download`` first and this
+        only when that gives no link.
         """
         """
+        return await self._get_bambu_api_json(
+            f"https://api.bambulab.com/v1/iot-service/api/user/profile/{int(profile_id)}",
+            {"model_id": str(model_id)},
+            "profile",
+            profile_id,
+        )
+
+    async def get_instance_download(self, instance_id: int) -> dict[str, Any]:
+        """Fetch the signed 3MF download URL for a MakerWorld instance.
+
+        Hits ``api.bambulab.com/v1/design-service/instance/{instanceId}/f3mf
+        ?type=download`` with the stored Bambu Cloud bearer; anonymously it
+        answers with an error. Returns ``{"name": "<file>.3mf", "url":
+        "https://makerworld.bblmw.com/...", "highCompression": ...}``
+        (measured 2026-11-04); without a sign-in it answers 403 "Please log
+        in to download models.", like a model the account can't download.
+        The instance id is the ``id`` of an entry in
+        the design's ``instances``, not its ``profileId``.
+        """
+        return await self._get_bambu_api_json(
+            f"https://api.bambulab.com/v1/design-service/instance/{int(instance_id)}/f3mf",
+            {"type": "download"},
+            "instance",
+            instance_id,
+        )
+
+    async def _instance_id_for_profile(
+        self, model_id: int, design: dict[str, Any], profile_id: int, envelope: dict[str, Any] | None = None
+    ) -> int | None:
+        """The instance behind *profile_id*: from the design's ``instances``,
+        else from the ``/design/{id}/instances`` envelope (*envelope* when the
+        caller already has it). None when neither lists it."""
+        for instance in design.get("instances") or []:
+            if isinstance(instance, dict) and instance.get("profileId") == profile_id:
+                if isinstance(instance.get("id"), int):
+                    return instance["id"]
+        if envelope is None:
+            envelope = await self.get_design_instances(model_id)
+        for hit in envelope.get("hits") or []:
+            if isinstance(hit, dict) and hit.get("profileId") == profile_id and isinstance(hit.get("id"), int):
+                return hit["id"]
+        return None
+
+    async def _get_bambu_api_json(
+        self, url: str, params: dict[str, str], kind: str, resource_id: int
+    ) -> dict[str, Any]:
+        """GET a Bambu Lab API download endpoint with the stored bearer,
+        mapping its status to the MakerWorld errors. *kind* and *resource_id*
+        name the resource in the messages ("profile 123")."""
         if not self._auth_token:
         if not self._auth_token:
             raise MakerWorldAuthError("Downloading files from MakerWorld requires a Bambu Cloud login")
             raise MakerWorldAuthError("Downloading files from MakerWorld requires a Bambu Cloud login")
 
 
-        url = f"https://api.bambulab.com/v1/iot-service/api/user/profile/{int(profile_id)}"
         headers = dict(_CLIENT_HEADERS)
         headers = dict(_CLIENT_HEADERS)
         headers["Authorization"] = f"Bearer {self._auth_token}"
         headers["Authorization"] = f"Bearer {self._auth_token}"
 
 
@@ -445,7 +506,7 @@ class MakerWorldService(ProviderService):
             response = await self._client.get(
             response = await self._client.get(
                 url,
                 url,
                 headers=headers,
                 headers=headers,
-                params={"model_id": str(model_id)},
+                params=params,
                 timeout=30.0,
                 timeout=30.0,
             )
             )
         except httpx.TimeoutException as exc:
         except httpx.TimeoutException as exc:
@@ -458,20 +519,20 @@ class MakerWorldService(ProviderService):
             raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
             raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
         if response.status_code == 403:
         if response.status_code == 403:
             upstream = _extract_upstream_error(response)
             upstream = _extract_upstream_error(response)
-            raise MakerWorldForbiddenError(upstream or f"Bambu Lab refused access to profile {profile_id}")
+            raise MakerWorldForbiddenError(upstream or f"Bambu Lab refused access to {kind} {resource_id}")
         if response.status_code == 404:
         if response.status_code == 404:
-            raise MakerWorldNotFoundError(f"MakerWorld profile not found: {profile_id}")
+            raise MakerWorldNotFoundError(f"MakerWorld {kind} not found: {resource_id}")
         if response.status_code != 200:
         if response.status_code != 200:
             raise MakerWorldUnavailableError(
             raise MakerWorldUnavailableError(
-                f"Bambu Lab API unexpected status {response.status_code} for profile {profile_id}"
+                f"Bambu Lab API unexpected status {response.status_code} for {kind} {resource_id}"
             )
             )
 
 
         try:
         try:
             data = response.json()
             data = response.json()
         except ValueError as exc:
         except ValueError as exc:
-            raise MakerWorldUnavailableError(f"Bambu Lab API returned non-JSON for profile {profile_id}") from exc
+            raise MakerWorldUnavailableError(f"Bambu Lab API returned non-JSON for {kind} {resource_id}") from exc
         if not isinstance(data, dict):
         if not isinstance(data, dict):
-            raise MakerWorldUnavailableError(f"Bambu Lab API returned unexpected JSON shape for profile {profile_id}")
+            raise MakerWorldUnavailableError(f"Bambu Lab API returned unexpected JSON shape for {kind} {resource_id}")
         return data
         return data
 
 
     async def download_3mf(self, signed_url: str) -> tuple[bytes, str]:
     async def download_3mf(self, signed_url: str) -> tuple[bytes, str]:

+ 179 - 25
backend/tests/unit/services/test_makerworld.py

@@ -366,10 +366,11 @@ class TestResolve:
 
 
 class TestGetDownload:
 class TestGetDownload:
     """``get_download`` — the interface-level "resource → signed 3MF URL"
     """``get_download`` — the interface-level "resource → signed 3MF URL"
-    flow the /makerworld/import route drives. The provider-specific dance
-    lives here: the iot-service endpoint needs the *alphanumeric* modelId
-    (not the integer design id), the profile falls back in two tiers, and
-    three malformed-upstream shapes must map to UnavailableError (502)."""
+    flow the /makerworld/import route drives. The profile falls back in two
+    tiers; the link comes from the instance behind the profile, and from the
+    iot-service profile endpoint (which needs the *alphanumeric* modelId, not
+    the integer design id) only when that gives none (#3447, #3448). Malformed
+    upstream shapes map to UnavailableError (502)."""
 
 
     @pytest.fixture
     @pytest.fixture
     def service(self):
     def service(self):
@@ -379,7 +380,7 @@ class TestGetDownload:
         design = {
         design = {
             "id": 1400373,
             "id": 1400373,
             "modelId": "US2bb73b106683e5",
             "modelId": "US2bb73b106683e5",
-            "instances": [{"profileId": 298919107, "title": "9 cells"}],
+            "instances": [{"id": 4028000, "profileId": 298919107, "title": "9 cells"}],
         }
         }
         design.update(overrides)
         design.update(overrides)
         return design
         return design
@@ -391,18 +392,23 @@ class TestGetDownload:
         return await service.get_download(ref)
         return await service.get_download(ref)
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_resolves_alphanumeric_model_id_and_explicit_profile(self, service):
-        """Explicit profile_id flows through; get_profile_download receives
-        the alphanumeric modelId from the design, not the integer id."""
-        service.get_design = AsyncMock(return_value=self._design())
+    async def test_the_link_comes_from_the_instance_behind_the_profile(self, service):
+        """Explicit profile_id: its instance is asked, the profile endpoint is not."""
+        service.get_design = AsyncMock(
+            return_value=self._design(
+                instances=[{"id": 1, "profileId": 5}, {"id": 4028000, "profileId": 298919107}],
+            )
+        )
         manifest = self._manifest()
         manifest = self._manifest()
-        service.get_profile_download = AsyncMock(return_value=manifest)
+        service.get_instance_download = AsyncMock(return_value=manifest)
+        service.get_profile_download = AsyncMock()
 
 
         info = await self._run(
         info = await self._run(
             service, ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="298919107")
             service, ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="298919107")
         )
         )
 
 
-        service.get_profile_download.assert_awaited_once_with(298919107, "US2bb73b106683e5")
+        service.get_instance_download.assert_awaited_once_with(4028000)
+        service.get_profile_download.assert_not_awaited()
         assert info.url == manifest["url"]
         assert info.url == manifest["url"]
         assert info.suggested_filename == "benchy.3mf"
         assert info.suggested_filename == "benchy.3mf"
         # The enriched ref carries the resolved profile for the dedupe key.
         # The enriched ref carries the resolved profile for the dedupe key.
@@ -412,31 +418,87 @@ class TestGetDownload:
     async def test_falls_back_to_first_design_instance_profile(self, service):
     async def test_falls_back_to_first_design_instance_profile(self, service):
         """No profile given → first ``design.instances[].profileId`` wins."""
         """No profile given → first ``design.instances[].profileId`` wins."""
         service.get_design = AsyncMock(return_value=self._design())
         service.get_design = AsyncMock(return_value=self._design())
-        service.get_profile_download = AsyncMock(return_value=self._manifest())
+        service.get_instance_download = AsyncMock(return_value=self._manifest())
 
 
         info = await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
         info = await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
 
 
-        service.get_profile_download.assert_awaited_once_with(298919107, "US2bb73b106683e5")
+        service.get_instance_download.assert_awaited_once_with(4028000)
         assert info.ref.sub_id == "298919107"
         assert info.ref.sub_id == "298919107"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_second_tier_falls_back_to_instances_envelope(self, service):
     async def test_second_tier_falls_back_to_instances_envelope(self, service):
         """Design carries no usable profileId → the ``/design/{id}/instances``
         """Design carries no usable profileId → the ``/design/{id}/instances``
-        envelope is consulted before giving up."""
+        envelope is consulted before giving up, once, for the instance too."""
         service.get_design = AsyncMock(return_value=self._design(instances=[{"title": "no profileId here"}]))
         service.get_design = AsyncMock(return_value=self._design(instances=[{"title": "no profileId here"}]))
-        service.get_design_instances = AsyncMock(return_value={"total": 1, "hits": [{"profileId": 298919564}]})
-        service.get_profile_download = AsyncMock(return_value=self._manifest())
+        service.get_design_instances = AsyncMock(
+            return_value={"total": 1, "hits": [{"id": 4028001, "profileId": 298919564}]}
+        )
+        service.get_instance_download = AsyncMock(return_value=self._manifest())
 
 
         info = await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
         info = await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
 
 
         service.get_design_instances.assert_awaited_once_with(1400373)
         service.get_design_instances.assert_awaited_once_with(1400373)
-        service.get_profile_download.assert_awaited_once_with(298919564, "US2bb73b106683e5")
+        service.get_instance_download.assert_awaited_once_with(4028001)
         assert info.ref.sub_id == "298919564"
         assert info.ref.sub_id == "298919564"
 
 
+    @pytest.mark.asyncio
+    async def test_an_instance_the_design_lists_without_id_is_found_in_the_envelope(self, service):
+        service.get_design = AsyncMock(return_value=self._design(instances=[{"profileId": 298919107}]))
+        service.get_design_instances = AsyncMock(
+            return_value={"total": 2, "hits": [{"id": 1, "profileId": 5}, {"id": 4028000, "profileId": 298919107}]}
+        )
+        service.get_instance_download = AsyncMock(return_value=self._manifest())
+
+        await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
+
+        service.get_instance_download.assert_awaited_once_with(4028000)
+
+    @pytest.mark.asyncio
+    async def test_no_link_from_the_instance_asks_the_profile(self, service):
+        """The profile endpoint gets the alphanumeric modelId, not the integer id."""
+        service.get_design = AsyncMock(return_value=self._design())
+        service.get_instance_download = AsyncMock(return_value={"name": "x.3mf", "url": ""})
+        manifest = self._manifest(name="from-profile.3mf")
+        service.get_profile_download = AsyncMock(return_value=manifest)
+
+        info = await self._run(
+            service, ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="298919107")
+        )
+
+        service.get_profile_download.assert_awaited_once_with(298919107, "US2bb73b106683e5")
+        assert info.url == manifest["url"]
+        assert info.suggested_filename == "from-profile.3mf"
+
+    @pytest.mark.asyncio
+    async def test_a_profile_no_instance_lists_asks_the_profile(self, service):
+        service.get_design = AsyncMock(return_value=self._design())
+        service.get_design_instances = AsyncMock(return_value={"total": 0, "hits": []})
+        service.get_instance_download = AsyncMock()
+        service.get_profile_download = AsyncMock(return_value=self._manifest())
+
+        info = await self._run(
+            service, ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="777")
+        )
+
+        service.get_instance_download.assert_not_awaited()
+        service.get_profile_download.assert_awaited_once_with(777, "US2bb73b106683e5")
+        assert info.ref.sub_id == "777"
+
+    @pytest.mark.asyncio
+    async def test_the_instance_link_needs_no_model_id(self, service):
+        service.get_design = AsyncMock(return_value=self._design(modelId=None))
+        service.get_instance_download = AsyncMock(return_value=self._manifest())
+
+        info = await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
+
+        assert info.url == self._manifest()["url"]
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_missing_alphanumeric_model_id_is_unavailable(self, service):
     async def test_missing_alphanumeric_model_id_is_unavailable(self, service):
-        """A design without the ``modelId`` field can't reach iot-service."""
-        service.get_design = AsyncMock(return_value={"id": 1400373})
+        """No link from the instance, and a design without the ``modelId``
+        field can't reach iot-service."""
+        service.get_design = AsyncMock(return_value=self._design(modelId=None))
+        service.get_instance_download = AsyncMock(return_value={"url": ""})
 
 
         with pytest.raises(MakerWorldUnavailableError, match="modelId"):
         with pytest.raises(MakerWorldUnavailableError, match="modelId"):
             await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
             await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
@@ -450,20 +512,34 @@ class TestGetDownload:
             await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
             await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_manifest_without_url_is_unavailable(self, service):
+    async def test_a_refusal_from_the_instance_is_not_retried_on_the_profile(self, service):
+        """Bambu's reason (paid, points, region) reaches the user rather than
+        the profile endpoint's empty link."""
         service.get_design = AsyncMock(return_value=self._design())
         service.get_design = AsyncMock(return_value=self._design())
-        service.get_profile_download = AsyncMock(return_value={"name": "benchy.3mf"})
+        service.get_instance_download = AsyncMock(side_effect=MakerWorldForbiddenError("paid model"))
+        service.get_profile_download = AsyncMock()
+
+        with pytest.raises(MakerWorldForbiddenError, match="paid model"):
+            await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
+        service.get_profile_download.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_no_link_from_either_is_unavailable(self, service):
+        """The reports: the profile answers with ``url: ""``, and here the instance does too."""
+        service.get_design = AsyncMock(return_value=self._design())
+        service.get_instance_download = AsyncMock(return_value={"name": "benchy.3mf"})
+        service.get_profile_download = AsyncMock(return_value={"name": "benchy.3mf", "url": ""})
 
 
         with pytest.raises(MakerWorldUnavailableError, match="download URL"):
         with pytest.raises(MakerWorldUnavailableError, match="download URL"):
             await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
             await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
 
 
 
 
 class TestGetProfileDownload:
 class TestGetProfileDownload:
-    """The new auth-gated 3MF manifest endpoint on the Bambu iot-service.
+    """The auth-gated 3MF manifest endpoint on the Bambu iot-service.
 
 
-    Replaces the removed ``get_instance_download`` / ``get_model_download``
-    helpers — YASTL#51's endpoint mints the signed CDN URL from the same
-    long-lived Bambu Cloud bearer users already have.
+    YASTL#51's endpoint minted the signed CDN URL from the same long-lived
+    Bambu Cloud bearer users already have; since 2026-11-03 it answers with
+    an empty ``url`` and is only the fallback behind the instance endpoint.
     """
     """
 
 
     def _make_service(self, *, auth_token: str | None = "tok-abc") -> MakerWorldService:
     def _make_service(self, *, auth_token: str | None = "tok-abc") -> MakerWorldService:
@@ -562,6 +638,84 @@ class TestGetProfileDownload:
             await svc.get_profile_download(1, "M1")
             await svc.get_profile_download(1, "M1")
 
 
 
 
+class TestGetInstanceDownload:
+    """The design-service instance endpoint on ``api.bambulab.com`` that
+    mints the signed URL since the profile endpoint stopped (#3447, #3448).
+    Same bearer, same status mapping, the resource named as an instance."""
+
+    def _make_service(self, *, auth_token: str | None = "tok-abc") -> MakerWorldService:
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), auth_token=auth_token)
+        svc._client.get = AsyncMock()
+        return svc
+
+    def _respond(self, svc, status, body=None):
+        resp = MagicMock()
+        resp.status_code = status
+        resp.json.return_value = body if body is not None else {}
+        svc._client.get.return_value = resp
+
+    @pytest.mark.asyncio
+    async def test_requires_auth_token(self):
+        svc = self._make_service(auth_token=None)
+        with pytest.raises(MakerWorldAuthError):
+            await svc.get_instance_download(4028000)
+        svc._client.get.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_sends_bearer_and_type_download(self):
+        svc = self._make_service(auth_token="tok-abc")
+        self._respond(
+            svc, 200, {"name": "m.3mf", "url": "https://makerworld.bblmw.com/m.3mf", "highCompression": False}
+        )
+
+        manifest = await svc.get_instance_download(4028000)
+
+        call = svc._client.get.call_args
+        url = call.args[0] if call.args else call.kwargs.get("url")
+        assert url == "https://api.bambulab.com/v1/design-service/instance/4028000/f3mf"
+        assert call.kwargs["headers"]["Authorization"] == "Bearer tok-abc"
+        assert call.kwargs["params"] == {"type": "download"}
+        assert manifest["name"] == "m.3mf"
+
+    @pytest.mark.asyncio
+    async def test_maps_401_to_auth_error(self):
+        svc = self._make_service()
+        self._respond(svc, 401, {"error": "token expired"})
+        with pytest.raises(MakerWorldAuthError):
+            await svc.get_instance_download(4028000)
+
+    @pytest.mark.asyncio
+    async def test_an_expired_sign_in_is_noted(self):
+        """The same sign-out as every other MakerWorld call (``_note_auth_failure``)."""
+        svc = self._make_service()
+        svc._note_auth_failure = AsyncMock()
+        self._respond(svc, 401, {"code": 4, "error": "Please login."})
+        with pytest.raises(MakerWorldAuthError):
+            await svc.get_instance_download(4028000)
+        svc._note_auth_failure.assert_awaited_once_with(svc._client.get.return_value)
+
+    @pytest.mark.asyncio
+    async def test_maps_403_to_forbidden_naming_the_instance(self):
+        svc = self._make_service()
+        self._respond(svc, 403, {})
+        with pytest.raises(MakerWorldForbiddenError, match="instance 4028000"):
+            await svc.get_instance_download(4028000)
+
+    @pytest.mark.asyncio
+    async def test_maps_404_to_not_found_naming_the_instance(self):
+        svc = self._make_service()
+        self._respond(svc, 404)
+        with pytest.raises(MakerWorldNotFoundError, match="instance not found: 4028000"):
+            await svc.get_instance_download(4028000)
+
+    @pytest.mark.asyncio
+    async def test_other_status_is_unavailable(self):
+        svc = self._make_service()
+        self._respond(svc, 500)
+        with pytest.raises(MakerWorldUnavailableError, match="for instance 4028000"):
+            await svc.get_instance_download(4028000)
+
+
 class TestDownload3MF:
 class TestDownload3MF:
     """SSRF guard + size cap + streaming behaviour."""
     """SSRF guard + size cap + streaming behaviour."""
 
 

File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-D6ilKR1P.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-DubFa_Dw.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-BVrd3vR_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfirmOutcomeDialog-BDm0_cKj.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileManagerPage-DrGUKdIV.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-ClVtn6VD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/Layout-B5C8BmRi.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-CvZ7CKle.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintModal-YM7BO4Q0.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-BV3mII1Y.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-6943sM3A.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueuePage-jmFFG5IP.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/RichTextEditor-OJtNVMZ3.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-CblK-4dZ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-BW_C0TgS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-Db2K5Uck.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-uoVsAX0j.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BuMh076b.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/locationSensorDefaults-AiAqjghu.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/spoolCost-CwamsM-z.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/usePageFileDrop-Bq6FfnIM.js


+ 4 - 4
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BuREgeDC.js"></script>
+    <script type="module" crossorigin src="/assets/index-BuMh076b.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-A76-KVrT.js">
     <link rel="modulepreload" crossorigin href="/assets/client-A76-KVrT.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-82e9FLNr.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-82e9FLNr.js">
@@ -99,7 +99,7 @@
     <link rel="modulepreload" crossorigin href="/assets/printer-DfB6SVGc.js">
     <link rel="modulepreload" crossorigin href="/assets/printer-DfB6SVGc.js">
     <link rel="modulepreload" crossorigin href="/assets/filamentSwatchHelpers-C3r5m07g.js">
     <link rel="modulepreload" crossorigin href="/assets/filamentSwatchHelpers-C3r5m07g.js">
     <link rel="modulepreload" crossorigin href="/assets/FilamentSwatch-BWlmGXcS.js">
     <link rel="modulepreload" crossorigin href="/assets/FilamentSwatch-BWlmGXcS.js">
-    <link rel="modulepreload" crossorigin href="/assets/PrintModal-BoE_PPQD.js">
+    <link rel="modulepreload" crossorigin href="/assets/PrintModal-YM7BO4Q0.js">
     <link rel="modulepreload" crossorigin href="/assets/download-CB6MgRVB.js">
     <link rel="modulepreload" crossorigin href="/assets/download-CB6MgRVB.js">
     <link rel="modulepreload" crossorigin href="/assets/eye-I2eeYCVj.js">
     <link rel="modulepreload" crossorigin href="/assets/eye-I2eeYCVj.js">
     <link rel="modulepreload" crossorigin href="/assets/folder-kanban-Cmh1v_Iv.js">
     <link rel="modulepreload" crossorigin href="/assets/folder-kanban-Cmh1v_Iv.js">
@@ -110,7 +110,7 @@
     <link rel="modulepreload" crossorigin href="/assets/trash-2-DV6pNwZ0.js">
     <link rel="modulepreload" crossorigin href="/assets/trash-2-DV6pNwZ0.js">
     <link rel="modulepreload" crossorigin href="/assets/weight-BrGbsav6.js">
     <link rel="modulepreload" crossorigin href="/assets/weight-BrGbsav6.js">
     <link rel="modulepreload" crossorigin href="/assets/projectTree-C5LrJHZP.js">
     <link rel="modulepreload" crossorigin href="/assets/projectTree-C5LrJHZP.js">
-    <link rel="modulepreload" crossorigin href="/assets/ConfirmOutcomeDialog-SoHiYPAU.js">
+    <link rel="modulepreload" crossorigin href="/assets/ConfirmOutcomeDialog-BDm0_cKj.js">
     <link rel="modulepreload" crossorigin href="/assets/key-eHBzEQ60.js">
     <link rel="modulepreload" crossorigin href="/assets/key-eHBzEQ60.js">
     <link rel="modulepreload" crossorigin href="/assets/log-out-w_j59-w-.js">
     <link rel="modulepreload" crossorigin href="/assets/log-out-w_j59-w-.js">
     <link rel="modulepreload" crossorigin href="/assets/shield-alert-uon1l8O-.js">
     <link rel="modulepreload" crossorigin href="/assets/shield-alert-uon1l8O-.js">
@@ -122,7 +122,7 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-D6NM9pNL.js">
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-D6NM9pNL.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-6gmNNa1B.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-6gmNNa1B.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CHLiTxan.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CHLiTxan.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-yabZzd9x.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-B5C8BmRi.js">
     <link rel="stylesheet" crossorigin href="/assets/index-C2XdLYj2.css">
     <link rel="stylesheet" crossorigin href="/assets/index-C2XdLYj2.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff