Explorar o código

Merge branch '1.2.5.6'

maziggy hai 1 día
pai
achega
d80414b518
Modificáronse 100 ficheiros con 8266 adicións e 1406 borrados
  1. 5 0
      .gitignore
  2. 7 0
      CHANGELOG.md
  3. 6 2
      backend/app/api/routes/ams_history.py
  4. 116 43
      backend/app/api/routes/archives.py
  5. 28 4
      backend/app/api/routes/auth.py
  6. 17 96
      backend/app/api/routes/cloud.py
  7. 6 3
      backend/app/api/routes/external_links.py
  8. 16 7
      backend/app/api/routes/finance.py
  9. 60 9
      backend/app/api/routes/inventory.py
  10. 172 164
      backend/app/api/routes/library.py
  11. 248 230
      backend/app/api/routes/makerworld.py
  12. 40 22
      backend/app/api/routes/orca_cloud.py
  13. 14 3
      backend/app/api/routes/print_log.py
  14. 24 10
      backend/app/api/routes/print_queue.py
  15. 122 68
      backend/app/api/routes/printers.py
  16. 10 4
      backend/app/api/routes/projects.py
  17. 240 6
      backend/app/api/routes/settings.py
  18. 2 1
      backend/app/api/routes/slicer_presets.py
  19. 8 0
      backend/app/api/routes/spoolbuddy.py
  20. 33 8
      backend/app/api/routes/spoolman_inventory.py
  21. 6 1
      backend/app/api/routes/support.py
  22. 269 18
      backend/app/core/auth.py
  23. 1 1
      backend/app/core/config.py
  24. 33 2
      backend/app/core/database.py
  25. 206 47
      backend/app/main.py
  26. 7 1
      backend/app/models/finance.py
  27. 23 1
      backend/app/models/library.py
  28. 5 1
      backend/app/schemas/archive.py
  29. 7 0
      backend/app/schemas/library.py
  30. 10 0
      backend/app/schemas/makerworld.py
  31. 5 3
      backend/app/schemas/notification.py
  32. 1 1
      backend/app/schemas/print_queue.py
  33. 6 2
      backend/app/schemas/printer.py
  34. 60 0
      backend/app/services/ams_slot_presence.py
  35. 118 0
      backend/app/services/bambu_cloud_credentials.py
  36. 51 7
      backend/app/services/bambu_ftp.py
  37. 75 31
      backend/app/services/bambu_mqtt.py
  38. 1 1
      backend/app/services/diagnostic_snapshot.py
  39. 99 1
      backend/app/services/discovery.py
  40. 12 4
      backend/app/services/external_camera.py
  41. 18 0
      backend/app/services/finance_balance.py
  42. 1 1
      backend/app/services/finance_billing.py
  43. 1 8
      backend/app/services/finance_defaults.py
  44. 1 1
      backend/app/services/github_backup.py
  45. 51 0
      backend/app/services/model_providers/__init__.py
  46. 327 0
      backend/app/services/model_providers/base.py
  47. 12 0
      backend/app/services/model_providers/makerworld/__init__.py
  48. 18 0
      backend/app/services/model_providers/makerworld/auth.py
  49. 44 0
      backend/app/services/model_providers/makerworld/errors.py
  50. 166 0
      backend/app/services/model_providers/makerworld/http.py
  51. 117 0
      backend/app/services/model_providers/makerworld/provider.py
  52. 188 231
      backend/app/services/model_providers/makerworld/service.py
  53. 82 0
      backend/app/services/model_providers/makerworld/url.py
  54. 60 0
      backend/app/services/model_providers/registry.py
  55. 3 1
      backend/app/services/mqtt_relay.py
  56. 3 1
      backend/app/services/mqtt_smart_plug.py
  57. 127 28
      backend/app/services/network_utils.py
  58. 9 0
      backend/app/services/notification_service.py
  59. 287 42
      backend/app/services/plate_thumbnail.py
  60. 1 1
      backend/app/services/preset_resolver.py
  61. 530 70
      backend/app/services/print_scheduler.py
  62. 14 2
      backend/app/services/print_storage.py
  63. 240 32
      backend/app/services/printer_diagnostic.py
  64. 5 34
      backend/app/services/printer_manager.py
  65. 27 8
      backend/app/services/slice_preview.py
  66. 112 4
      backend/app/services/slicer_filament_resolver.py
  67. 40 0
      backend/app/services/tag_conflict.py
  68. 58 12
      backend/app/services/virtual_printer/certificate.py
  69. 40 0
      backend/app/utils/ams_humidity.py
  70. 9 6
      backend/app/utils/fts_routing.py
  71. 65 19
      backend/app/utils/kprofile_lookup.py
  72. 96 0
      backend/app/utils/paho_teardown.py
  73. 122 0
      backend/app/utils/threemf_tools.py
  74. 36 0
      backend/tests/_fixtures/external_camera.py
  75. 38 0
      backend/tests/integration/test_ams_history_api.py
  76. 133 0
      backend/tests/integration/test_archives_api.py
  77. 89 0
      backend/tests/integration/test_backup_manifest.py
  78. 17 9
      backend/tests/integration/test_cloud_auth.py
  79. 5 5
      backend/tests/integration/test_cloud_token_auth_migration.py
  80. 303 0
      backend/tests/integration/test_external_spool_use_ams_3087.py
  81. 77 0
      backend/tests/integration/test_finance_api.py
  82. 360 2
      backend/tests/integration/test_inventory_assign.py
  83. 155 0
      backend/tests/integration/test_inventory_link_tag.py
  84. 204 9
      backend/tests/integration/test_library_api.py
  85. 60 38
      backend/tests/integration/test_makerworld_apikey_auth.py
  86. 180 0
      backend/tests/integration/test_makerworld_permission_gate.py
  87. 425 0
      backend/tests/integration/test_media_token_3025.py
  88. 95 0
      backend/tests/integration/test_ownership_permissions.py
  89. 30 0
      backend/tests/integration/test_print_queue_api.py
  90. 89 21
      backend/tests/integration/test_printers_api.py
  91. 18 14
      backend/tests/integration/test_projects_api.py
  92. 25 0
      backend/tests/integration/test_queue_variants_api.py
  93. 1 1
      backend/tests/integration/test_scheduler_budget_reservation.py
  94. 25 4
      backend/tests/integration/test_security.py
  95. 169 0
      backend/tests/integration/test_settings_ui_flags_3023.py
  96. 289 0
      backend/tests/integration/test_slicer_token_reuse_3029.py
  97. 136 0
      backend/tests/integration/test_spoolbuddy_color_name_3090.py
  98. 113 0
      backend/tests/integration/test_spoolman_inventory_api.py
  99. 45 0
      backend/tests/unit/services/test_ams_slot_presence.py
  100. 106 0
      backend/tests/unit/services/test_bambu_cloud_credentials.py

+ 5 - 0
.gitignore

@@ -98,3 +98,8 @@ security/
 
 
 test_pipeline_archive_source.3mf
 test_pipeline_archive_source.3mf
 test_pipeline_run_1.3mf
 test_pipeline_run_1.3mf
+
+# Python coverage artifacts
+.coverage
+.coverage.*
+htmlcov/

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 7 - 0
CHANGELOG.md


+ 6 - 2
backend/app/api/routes/ams_history.py

@@ -93,10 +93,14 @@ async def get_ams_history(
         ],
         ],
         min_humidity=stats.min_humidity,
         min_humidity=stats.min_humidity,
         max_humidity=stats.max_humidity,
         max_humidity=stats.max_humidity,
-        avg_humidity=round(stats.avg_humidity, 1) if stats.avg_humidity else None,
+        # ``is not None``, not truthiness: an average of exactly 0 is a
+        # reading, and the min/max beside it would report it while the average
+        # showed an em dash. AVG over an empty or all-NULL window is the only
+        # case that has no answer (#3140).
+        avg_humidity=round(stats.avg_humidity, 1) if stats.avg_humidity is not None else None,
         min_temperature=stats.min_temp,
         min_temperature=stats.min_temp,
         max_temperature=stats.max_temp,
         max_temperature=stats.max_temp,
-        avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp else None,
+        avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp is not None else None,
     )
     )
 
 
 
 

+ 116 - 43
backend/app/api/routes/archives.py

@@ -16,11 +16,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core import database
 from backend.app.core import database
 from backend.app.core.auth import (
 from backend.app.core.auth import (
-    RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     check_printer_access,
     check_printer_access,
     current_api_key_if_present,
     current_api_key_if_present,
     probe_permissions_if_auth_enabled,
     probe_permissions_if_auth_enabled,
+    require_media_token_ownership,
     require_ownership_permission,
     require_ownership_permission,
 )
 )
 from backend.app.core.config import settings
 from backend.app.core.config import settings
@@ -40,6 +40,8 @@ from backend.app.services.bambu_ftp import ftps_handshake_blocked, list_files_re
 from backend.app.services.design_settings import overrides_from_config
 from backend.app.services.design_settings import overrides_from_config
 from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.services.print_storage import (
 from backend.app.services.print_storage import (
+    REASON_FTP_TRANSFER_FAILED,
+    REASON_FTPS_COOLOFF,
     REASON_INTERNAL_HISTORY,
     REASON_INTERNAL_HISTORY,
     REASON_INTERNAL_STORAGE,
     REASON_INTERNAL_STORAGE,
     REASON_NO_EXTERNAL_STORAGE,
     REASON_NO_EXTERNAL_STORAGE,
@@ -537,8 +539,8 @@ async def no_3mf_warning(
     single-cause wording sent people the wrong way. Historically the only
     single-cause wording sent people the wrong way. Historically the only
     known cause was install step 4 ("Store sent files on external storage")
     known cause was install step 4 ("Store sent files on external storage")
     being off in the slicer, so the banner said so unconditionally. On
     being off in the slicer, so the banner said so unconditionally. On
-    H2-series and P2S that advice is actively wrong: the setting is already on
-    and turning it on again changes nothing, because the printer keeps the
+    H2-series, P2S and X2D that advice is actively wrong: the setting is already
+    on and turning it on again changes nothing, because the printer keeps the
     sliced file on internal storage that FTPS does not serve at all (#2780).
     sliced file on internal storage that FTPS does not serve at all (#2780).
 
 
     ``reason`` is the slug from :mod:`print_storage` when we recorded one,
     ``reason`` is the slug from :mod:`print_storage` when we recorded one,
@@ -581,12 +583,41 @@ async def no_3mf_warning(
     # all, so an install with one H2C and three older printers still gets the
     # all, so an install with one H2C and three older printers still gets the
     # H2C explanation rather than the generic one.
     # H2C explanation rather than the generic one.
     #
     #
+    # REASON_FTPS_COOLOFF leads, and it is the only one of these that reports a
+    # fault rather than a choice: the printer's file service refused a TLS
+    # handshake, so the sweep never ran and nothing about where the file went
+    # was ever tested. The other three describe an install working as
+    # configured, and each ends in something the operator can change. This one
+    # ends in "your printer is doing something we cannot yet explain", which is
+    # both the more urgent thing to say and the thing that produces a useful
+    # report. It also has to outrank them because the banner dismisses one-shot
+    # into localStorage: a reason ranked below another is not merely deferred,
+    # it is never shown to that user again (#2780).
+    #
+    # Ranking it first cannot mask a permanent cause, because a cool-off row is
+    # not permanent. The retry #2957 schedules clears the row's markers when it
+    # lands, so a row still carrying this slug is one where the retry failed too
+    # -- a printer whose file service is still refusing, days later.
+    #
+    # REASON_FTP_TRANSFER_FAILED sits second for the same reasons and one more:
+    # it is the only slug here whose remedy is a Bambuddy setting rather than a
+    # slicer one or a card. It ranks below the cool-off because a printer that
+    # will not complete a TLS handshake is the worse fault of the two, and its
+    # own retry (#3063) clears the row the same way, so a row still carrying
+    # this slug is one where three later attempts also ran out of time.
+    #
     # REASON_INTERNAL_HISTORY comes last on purpose, even though it is the
     # REASON_INTERNAL_HISTORY comes last on purpose, even though it is the
     # narrowest: it is the one cause with no remedy at all -- the file was
     # narrowest: it is the one cause with no remedy at all -- the file was
     # already on the printer, in an area port 990 does not serve. The two ahead
     # already on the printer, in an area port 990 does not serve. The two ahead
     # of it each end in something the operator can do, so when an install has
     # of it each end in something the operator can do, so when an install has
     # both, the actionable explanation is the one worth the banner (#1820).
     # both, the actionable explanation is the one worth the banner (#1820).
-    for candidate in (REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE, REASON_INTERNAL_HISTORY):
+    for candidate in (
+        REASON_FTPS_COOLOFF,
+        REASON_FTP_TRANSFER_FAILED,
+        REASON_INTERNAL_STORAGE,
+        REASON_NO_EXTERNAL_STORAGE,
+        REASON_INTERNAL_HISTORY,
+    ):
         if candidate in reasons:
         if candidate in reasons:
             return {"has_fallback": True, "reason": candidate}
             return {"has_fallback": True, "reason": candidate}
     return {"has_fallback": True, "reason": None}
     return {"has_fallback": True, "reason": None}
@@ -2291,13 +2322,15 @@ async def download_archive_for_slicer(
 ):
 ):
     """Download 3MF file using a slicer download token.
     """Download 3MF file using a slicer download token.
 
 
-    Token-authenticated (no auth headers needed). The token is short-lived
-    and single-use, created by POST /{archive_id}/slicer-token.
+    Token-authenticated (no auth headers needed). The token is short-lived and
+    archive-bound, created by POST /{archive_id}/slicer-token, and redeemable
+    for the rest of its TTL rather than exactly once -- the slicer is a separate
+    process that may fetch the URL more than once (#3029).
     Filename is at the end of the URL so slicers can detect the file format.
     Filename is at the end of the URL so slicers can detect the file format.
     """
     """
     from backend.app.core.auth import verify_slicer_download_token
     from backend.app.core.auth import verify_slicer_download_token
 
 
-    if not await verify_slicer_download_token(token, "archive", archive_id):
+    if not await verify_slicer_download_token(token, "archive", archive_id, single_use=False):
         raise HTTPException(403, "Invalid or expired download token")
         raise HTTPException(403, "Invalid or expired download token")
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
@@ -2320,15 +2353,22 @@ async def download_archive_for_slicer(
 async def get_thumbnail(
 async def get_thumbnail(
     archive_id: int,
     archive_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
 ):
     """Get the thumbnail image.
     """Get the thumbnail image.
 
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
     """
+    user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive or not archive.thumbnail_path:
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    if not archive.thumbnail_path:
         raise HTTPException(404, "Thumbnail not found")
         raise HTTPException(404, "Thumbnail not found")
 
 
     thumb_path = settings.base_dir / archive.thumbnail_path
     thumb_path = settings.base_dir / archive.thumbnail_path
@@ -2549,15 +2589,22 @@ async def download_archive_media_with_token(
 async def get_timelapse(
 async def get_timelapse(
     archive_id: int,
     archive_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
 ):
     """Get the timelapse video.
     """Get the timelapse video.
 
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
     """
+    user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive or not archive.timelapse_path:
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    if not archive.timelapse_path:
         raise HTTPException(404, "Timelapse not found")
         raise HTTPException(404, "Timelapse not found")
 
 
     timelapse_path = settings.base_dir / archive.timelapse_path
     timelapse_path = settings.base_dir / archive.timelapse_path
@@ -3265,16 +3312,21 @@ async def get_photo(
     archive_id: int,
     archive_id: int,
     filename: str,
     filename: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
 ):
     """Get a specific photo.
     """Get a specific photo.
 
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
     """
+    user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
 
 
     # Membership check first — UUID-generated names on upload mean any URL
     # Membership check first — UUID-generated names on upload mean any URL
     # filename that doesn't appear here is by definition not a real photo.
     # filename that doesn't appear here is by definition not a real photo.
@@ -3353,12 +3405,19 @@ async def get_qrcode(
     request: Request,
     request: Request,
     size: int = 200,
     size: int = 200,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
 ):
     """Generate a QR code that links to this archive.
     """Generate a QR code that links to this archive.
 
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
     """
+    user, can_read_all = auth_result
     try:
     try:
         import qrcode
         import qrcode
         from PIL import Image as PILImage
         from PIL import Image as PILImage
@@ -3366,9 +3425,7 @@ async def get_qrcode(
         raise HTTPException(500, "QR code generation not available - qrcode package not installed")
         raise HTTPException(500, "QR code generation not available - qrcode package not installed")
 
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
 
 
     # Build URL to archive download
     # Build URL to archive download
     base_url = str(request.base_url).rstrip("/")
     base_url = str(request.base_url).rstrip("/")
@@ -3694,19 +3751,24 @@ async def get_gcode(
 async def get_plate_preview(
 async def get_plate_preview(
     archive_id: int,
     archive_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
 ):
     """Get the plate preview image from the 3MF file.
     """Get the plate preview image from the 3MF file.
 
 
     Returns the slicer-generated plate thumbnail which shows the model
     Returns the slicer-generated plate thumbnail which shows the model
     with correct colors and positioning.
     with correct colors and positioning.
 
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
     """
+    user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -4227,16 +4289,21 @@ async def get_plate_thumbnail(
     archive_id: int,
     archive_id: int,
     plate_index: int,
     plate_index: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
 ):
     """Get the thumbnail image for a specific plate.
     """Get the thumbnail image for a specific plate.
 
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
     """
+    user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -4678,18 +4745,23 @@ async def get_project_image(
     archive_id: int,
     archive_id: int,
     image_path: str,
     image_path: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
 ):
     """Get an image from the 3MF project page.
     """Get an image from the 3MF project page.
 
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
     """
+    user, can_read_all = auth_result
     from backend.app.services.archive import ProjectPageParser
     from backend.app.services.archive import ProjectPageParser
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -4910,12 +4982,13 @@ async def download_source_3mf_for_slicer_with_token(
 ):
 ):
     """Download source 3MF using a slicer download token.
     """Download source 3MF using a slicer download token.
 
 
-    Token-authenticated (no auth headers needed). The token is short-lived
-    and single-use, created by POST /{archive_id}/source-slicer-token.
+    Token-authenticated (no auth headers needed). The token is short-lived and
+    archive-bound, created by POST /{archive_id}/source-slicer-token, and
+    redeemable for the rest of its TTL rather than exactly once (#3029).
     """
     """
     from backend.app.core.auth import verify_slicer_download_token
     from backend.app.core.auth import verify_slicer_download_token
 
 
-    if not await verify_slicer_download_token(token, "source", archive_id):
+    if not await verify_slicer_download_token(token, "source", archive_id, single_use=False):
         raise HTTPException(403, "Invalid or expired download token")
         raise HTTPException(403, "Invalid or expired download token")
 
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))

+ 28 - 4
backend/app/api/routes/auth.py

@@ -25,12 +25,14 @@ from backend.app.core.auth import (
     authenticate_user,
     authenticate_user,
     authenticate_user_by_email,
     authenticate_user_by_email,
     create_access_token,
     create_access_token,
+    create_media_token,
     create_websocket_token,
     create_websocket_token,
     get_current_active_user,
     get_current_active_user,
     get_password_hash,
     get_password_hash,
     get_user_by_email,
     get_user_by_email,
     get_user_by_username,
     get_user_by_username,
     is_jti_revoked,
     is_jti_revoked,
+    require_auth_if_enabled,
     resolve_apikey_owner,
     resolve_apikey_owner,
     resolve_session_max_minutes,
     resolve_session_max_minutes,
     revoke_jti,
     revoke_jti,
@@ -346,10 +348,8 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
             # (#2530). Only migrate when there is exactly one obvious owner:
             # (#2530). Only migrate when there is exactly one obvious owner:
             # handing another admin's session a Bambu credential is not a
             # handing another admin's session a Bambu credential is not a
             # guess worth making.
             # guess worth making.
-            from backend.app.api.routes.cloud import (
-                get_stored_token,
-                migrate_global_cloud_token_to_user,
-            )
+            from backend.app.api.routes.cloud import migrate_global_cloud_token_to_user
+            from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
             if admin_created:
             if admin_created:
                 cloud_owner = admin_user
                 cloud_owner = admin_user
@@ -659,6 +659,30 @@ async def mint_websocket_token(
     return {"token": await create_websocket_token(username)}
     return {"token": await create_websocket_token(username)}
 
 
 
 
+@router.post("/media-token")
+async def mint_media_token(
+    current_user: User | None = Depends(require_auth_if_enabled),
+):
+    """Mint a short-lived token for ``<img>`` / ``<video>`` media routes (#3025).
+
+    Thumbnails, plate previews, timelapses, cover images and sidebar icons are
+    loaded by the browser as element ``src`` URLs, which cannot carry an
+    ``Authorization`` header. Those routes used to accept the *camera stream*
+    token instead, which made ``camera:view`` a prerequisite for seeing a
+    library thumbnail -- on a home install, handing someone the live feed of
+    the room the printer is in just so their own files render.
+
+    So this mints behind plain authentication: any signed-in user may ask, and
+    what the token can actually reach is decided per request by the same
+    permission and ownership rules as the resource's other routes. It is not a
+    camera credential and does not open the camera routes.
+
+    Returns ``{"token": <opaque string>}``, valid for 60 minutes.
+    """
+    username = current_user.username if current_user is not None else None
+    return {"token": await create_media_token(username)}
+
+
 @router.get("/me", response_model=UserResponse)
 @router.get("/me", response_model=UserResponse)
 async def get_current_user_info(
 async def get_current_user_info(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,

+ 17 - 96
backend/app/api/routes/cloud.py

@@ -7,7 +7,6 @@ Handles authentication and profile management with Bambu Cloud.
 import asyncio
 import asyncio
 import json
 import json
 import logging
 import logging
-from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 from typing import Literal
 from typing import Literal
 
 
@@ -23,7 +22,7 @@ from backend.app.core.auth import (
     require_permission_if_auth_enabled,
     require_permission_if_auth_enabled,
     security,
     security,
 )
 )
-from backend.app.core.database import async_session, get_db
+from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
@@ -50,6 +49,22 @@ from backend.app.services.bambu_cloud import (
     BambuCloudService,
     BambuCloudService,
     invalidate_validation_cache,
     invalidate_validation_cache,
 )
 )
+
+# Credential read/write lives in the services layer so feature packages can
+# consume it without importing the route layer. Imported here for this
+# module's own use; consumers should import from bambu_cloud_credentials
+# directly rather than through this route module.
+from backend.app.services.bambu_cloud_credentials import (
+    CLOUD_EMAIL_KEY,
+    CLOUD_REGION_KEY,
+    CLOUD_TOKEN_INVALID_KEY,
+    CLOUD_TOKEN_KEY,
+    _clear_cloud_token_invalid,
+    _normalise_region,
+    get_stored_token,
+    is_cloud_token_invalid,
+    mark_cloud_token_invalid,
+)
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -166,100 +181,6 @@ async def resolve_api_key_cloud_owner(
 router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
 router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
 
 
 
 
-# Keys for storing cloud credentials in settings
-CLOUD_TOKEN_KEY = "bambu_cloud_token"
-CLOUD_EMAIL_KEY = "bambu_cloud_email"
-CLOUD_REGION_KEY = "bambu_cloud_region"
-# Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
-# an ISO timestamp; absent/empty means "not known to be dead".
-CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
-
-
-def _normalise_region(region: str | None) -> str:
-    """Treat NULL/empty as 'global' for legacy rows that predate the region column."""
-    return region if region in ("global", "china") else "global"
-
-
-async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
-    """Whether the stored Bambu token is known to have been rejected.
-
-    Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
-    cleared on a fresh login/logout. This is the only durable record we have:
-    Bambu's access token is opaque (no readable expiry) and Bambuddy does not
-    persist the refresh token, so without this flag a dead credential looks
-    exactly like a live one.
-    """
-    if user is not None:
-        return user.cloud_token_invalid_at is not None
-    result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
-    row = result.scalar_one_or_none()
-    return bool(row and row.value)
-
-
-async def mark_cloud_token_invalid(user_id: int | None) -> None:
-    """Record that Bambu rejected the stored token.
-
-    Opens its own session on purpose. This runs from
-    ``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
-    is about to fail — writing through that route's session would tie the flag
-    to a transaction the route may still roll back, and the fact that the
-    credential is dead is true regardless of how the request ends.
-
-    Best-effort: a bookkeeping failure must never replace the 401 the caller
-    actually needs to see.
-    """
-    now = datetime.now(timezone.utc)
-    try:
-        async with async_session() as db:
-            if user_id is not None:
-                await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
-            else:
-                result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
-                row = result.scalar_one_or_none()
-                if row:
-                    row.value = now.isoformat()
-                else:
-                    db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
-            await db.commit()
-        logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
-    except Exception:
-        logger.exception("Could not record the Bambu Cloud token as invalid")
-
-
-async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
-    """Clear the rejected-token flag — called on every fresh login and logout."""
-    if user is not None:
-        await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
-        return
-    result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
-    row = result.scalar_one_or_none()
-    if row:
-        await db.delete(row)
-
-
-async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
-    """Get stored cloud token, email, and region.
-
-    When a user is provided (auth enabled), returns that user's per-user credentials.
-    When user is None (auth disabled), falls back to global Settings table.
-    Region defaults to ``"global"`` when unset (including for rows that predate
-    the ``cloud_region`` column).
-    """
-    if user is not None:
-        return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)
-
-    # Fallback: global storage (auth disabled)
-    result = await db.execute(
-        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
-    )
-    settings = {s.key: s.value for s in result.scalars().all()}
-    return (
-        settings.get(CLOUD_TOKEN_KEY),
-        settings.get(CLOUD_EMAIL_KEY),
-        _normalise_region(settings.get(CLOUD_REGION_KEY)),
-    )
-
-
 async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
 async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
     """Store cloud token, email, and region.
     """Store cloud token, email, and region.
 
 

+ 6 - 3
backend/app/api/routes/external_links.py

@@ -9,7 +9,7 @@ from fastapi.responses import FileResponse
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_media_token_permission
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
@@ -239,11 +239,14 @@ async def delete_icon(
 async def get_icon(
 async def get_icon(
     link_id: int,
     link_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    _: User | None = Depends(require_media_token_permission(Permission.EXTERNAL_LINKS_READ)),
 ):
 ):
     """Get the custom icon for an external link.
     """Get the custom icon for an external link.
 
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    the same ``external_links:read`` every other read on this router takes.
+    Previously it took the camera-stream token, so a sidebar icon was visible
+    only to users who could also watch the printer camera (#3025).
     """
     """
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     link = result.scalar_one_or_none()
     link = result.scalar_one_or_none()

+ 16 - 7
backend/app/api/routes/finance.py

@@ -41,6 +41,7 @@ from backend.app.services.finance_balance import (
     calculate_personal_balance,
     calculate_personal_balance,
     is_personal_transaction,
     is_personal_transaction,
     personal_balance_condition,
     personal_balance_condition,
+    resolve_configured_currency,
     sync_personal_wallet_balance,
     sync_personal_wallet_balance,
 )
 )
 from backend.app.services.finance_budget import get_cost_center_reserved_map
 from backend.app.services.finance_budget import get_cost_center_reserved_map
@@ -251,7 +252,7 @@ async def _get_or_create_wallet(db: AsyncSession, user_id: int) -> UserWallet:
     if wallet:
     if wallet:
         return wallet
         return wallet
 
 
-    wallet = UserWallet(user_id=user_id, balance=0.0, currency="EUR")
+    wallet = UserWallet(user_id=user_id, balance=0.0)
     db.add(wallet)
     db.add(wallet)
     await db.flush()
     await db.flush()
     await db.refresh(wallet)
     await db.refresh(wallet)
@@ -276,24 +277,31 @@ async def _get_cost_center_or_404(db: AsyncSession, cost_center_id: int) -> Cost
     return center
     return center
 
 
 
 
-def _to_balance_response(wallet: UserWallet) -> WalletBalanceResponse:
+def _to_balance_response(wallet: UserWallet, currency: str) -> WalletBalanceResponse:
+    """Serialize a wallet, reporting the install's configured currency.
+
+    The wallet row holds no currency of its own: an install has exactly one,
+    and an admin who changes it expects every balance to follow, the way the
+    rest of the app does (#3123).
+    """
     return WalletBalanceResponse(
     return WalletBalanceResponse(
         user_id=wallet.user_id,
         user_id=wallet.user_id,
         balance=wallet.balance,
         balance=wallet.balance,
-        currency=wallet.currency,
+        currency=currency,
         updated_at=wallet.updated_at,
         updated_at=wallet.updated_at,
     )
     )
 
 
 
 
 async def _get_wallet_balance_read_only(db: AsyncSession, user_id: int) -> WalletBalanceResponse:
 async def _get_wallet_balance_read_only(db: AsyncSession, user_id: int) -> WalletBalanceResponse:
     """Return a balance without creating a wallet row from a GET request."""
     """Return a balance without creating a wallet row from a GET request."""
+    currency = await resolve_configured_currency(db)
     wallet = await db.scalar(select(UserWallet).where(UserWallet.user_id == user_id))
     wallet = await db.scalar(select(UserWallet).where(UserWallet.user_id == user_id))
     if wallet is not None:
     if wallet is not None:
-        return _to_balance_response(wallet)
+        return _to_balance_response(wallet, currency)
     return WalletBalanceResponse(
     return WalletBalanceResponse(
         user_id=user_id,
         user_id=user_id,
         balance=await calculate_personal_balance(db, user_id),
         balance=await calculate_personal_balance(db, user_id),
-        currency="EUR",
+        currency=currency,
         updated_at=None,
         updated_at=None,
     )
     )
 
 
@@ -386,15 +394,16 @@ async def _create_wallet_adjustment(
     await db.refresh(tx)
     await db.refresh(tx)
 
 
     # Return appropriate balance based on transaction type
     # Return appropriate balance based on transaction type
+    currency = await resolve_configured_currency(db)
     if affects_personal_wallet:
     if affects_personal_wallet:
         # Personal transaction: return user wallet balance
         # Personal transaction: return user wallet balance
-        response_balance = _to_balance_response(wallet)
+        response_balance = _to_balance_response(wallet, currency)
     else:
     else:
         # Cost-center transaction: return cost-center balance as if it were a wallet
         # Cost-center transaction: return cost-center balance as if it were a wallet
         response_balance = WalletBalanceResponse(
         response_balance = WalletBalanceResponse(
             user_id=target_user_id,
             user_id=target_user_id,
             balance=balance_after,
             balance=balance_after,
-            currency=wallet.currency,
+            currency=currency,
             updated_at=tx.created_at,
             updated_at=tx.created_at,
         )
         )
 
 

+ 60 - 9
backend/app/api/routes/inventory.py

@@ -45,6 +45,7 @@ from backend.app.schemas.spool import (
     normalize_extra_colors,
     normalize_extra_colors,
 )
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
+from backend.app.services.ams_slot_presence import spool_present
 from backend.app.services.location_service import (
 from backend.app.services.location_service import (
     DUPLICATE_LOCATION_NAME,
     DUPLICATE_LOCATION_NAME,
     assign_location_name,
     assign_location_name,
@@ -66,6 +67,7 @@ from backend.app.services.spool_csv import (
 )
 )
 from backend.app.services.spool_filament_preset import resolve_spool_preset
 from backend.app.services.spool_filament_preset import resolve_spool_preset
 from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
 from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
+from backend.app.services.tag_conflict import tag_already_linked
 from backend.app.utils.filament_ids import (
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_FILAMENT_IDS,
     filament_id_to_setting_id,
     filament_id_to_setting_id,
@@ -1850,6 +1852,9 @@ async def assign_spool(
     fingerprint_type = None
     fingerprint_type = None
     current_tray_info_idx = ""
     current_tray_info_idx = ""
     tray_state: int | None = None
     tray_state: int | None = None
+    # Firmware's tray_exist_bits answer for this slot, when the payload carries
+    # one. Outranks tray_state below — see services/ams_slot_presence.py.
+    tray_has_spool: bool | None = None
     state = printer_manager.get_status(data.printer_id)
     state = printer_manager.get_status(data.printer_id)
     if state and state.raw_data:
     if state and state.raw_data:
         if data.ams_id == 255:
         if data.ams_id == 255:
@@ -1864,6 +1869,7 @@ async def assign_spool(
                     raw_state = vt.get("state")
                     raw_state = vt.get("state")
                     if isinstance(raw_state, int):
                     if isinstance(raw_state, int):
                         tray_state = raw_state
                         tray_state = raw_state
+                    tray_has_spool = spool_present(vt)
                     break
                     break
         else:
         else:
             ams_data = state.raw_data.get("ams", {})
             ams_data = state.raw_data.get("ams", {})
@@ -1886,6 +1892,7 @@ async def assign_spool(
                 raw_state = tray.get("state")
                 raw_state = tray.get("state")
                 if isinstance(raw_state, int):
                 if isinstance(raw_state, int):
                     tray_state = raw_state
                     tray_state = raw_state
+                tray_has_spool = spool_present(tray)
 
 
     # 3. Upsert assignment (replace if same printer+ams+tray)
     # 3. Upsert assignment (replace if same printer+ams+tray)
     existing = await db.execute(
     existing = await db.execute(
@@ -1945,7 +1952,30 @@ async def assign_spool(
     # a doomed MQTT push when the firmware has positively confirmed "no
     # a doomed MQTT push when the firmware has positively confirmed "no
     # spool" — and to keep the on_ams_change replay path as the single
     # spool" — and to keep the on_ams_change replay path as the single
     # source of truth for those slots.
     # source of truth for those slots.
-    slot_is_definitely_empty = tray_state == 9 or tray_state == 10
+    #
+    # ...except that `state` cannot carry that meaning. Two independent ways
+    # a loaded slot reads 9 here:
+    #
+    #   - an AMS-HT reports its LOADED tray as 9, not 11, because it does not
+    #     feed into a shared buffer the way a 4-slot AMS does (#2594, and the
+    #     merge above skips its own state heuristic for HT units for exactly
+    #     this reason). So this branch called every HT slot empty on sight.
+    #   - apply_tray_exist_bits stamps state=9 on any slot whose tray_exist_bits
+    #     bit is 0 and never takes it back when the bit returns, so a slot that
+    #     was briefly emptied keeps the 9 until something configures it.
+    #
+    # Either way the slot sits at exists=True, state=9, this branch took the
+    # pending path, nothing was published, and the printer kept showing "?"
+    # (#3084 — reported against an H2C's AMS-HT, where both apply). Firmware's
+    # presence bit is what actually answers "is a spool in this slot", and the
+    # printer card has read it ahead of `state` since #2527.
+    #
+    # It is allowed to overrule the 9 and nothing else. A bit reading *empty*
+    # deliberately does NOT start suppressing pushes that go out today: the
+    # cost of being wrong there is a slot that silently stops configuring, on
+    # whichever AMS variant we compute the bit position wrong for, against a
+    # saving of one MQTT message the firmware would have dropped anyway.
+    slot_is_definitely_empty = tray_has_spool is not True and (tray_state == 9 or tray_state == 10)
     configured = False
     configured = False
     if not slot_is_definitely_empty:
     if not slot_is_definitely_empty:
         try:
         try:
@@ -2082,7 +2112,12 @@ async def link_tag_to_spool(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
-    """Link an RFID tag_uid/tray_uuid to an existing spool."""
+    """Link an RFID tag_uid/tray_uuid to an existing spool.
+
+    A tag another active spool already carries is refused with the shared
+    ``tag_already_linked`` 409, which names that spool so a caller can offer
+    to move the tag instead of only reporting that it is taken (#3110).
+    """
     result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
     result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
     spool = result.scalar_one_or_none()
     spool = result.scalar_one_or_none()
     if not spool:
     if not spool:
@@ -2096,17 +2131,30 @@ async def link_tag_to_spool(
     _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
     _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
     _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
     _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
 
 
-    # Check for conflicts: tag already linked to another active spool
+    # Check for conflicts: tag already linked to another active spool.
+    #
+    # Ordered, and read with first() rather than scalar_one_or_none(), because
+    # two active spools really can carry one tag: neither column has a unique
+    # index, PATCH /spools/{id} writes them with no conflict check, and
+    # POST /spools/bulk copies a single payload -- tag included -- into every
+    # row it creates. scalar_one_or_none() answered that with MultipleResultsFound,
+    # which escapes into the auth middleware's fail-closed handler and reaches
+    # the caller as 503 "Authentication service temporarily unavailable" -- a
+    # wrong answer pointing at the wrong subsystem, where a 409 was owed
+    # (#3110). get_spool_by_tag above already resolves duplicates this way.
     if normalized_tag_uid:
     if normalized_tag_uid:
         conflict = await db.execute(
         conflict = await db.execute(
-            select(Spool).where(
+            select(Spool)
+            .where(
                 func.upper(Spool.tag_uid) == normalized_tag_uid,
                 func.upper(Spool.tag_uid) == normalized_tag_uid,
                 Spool.id != spool_id,
                 Spool.id != spool_id,
                 Spool.archived_at.is_(None),
                 Spool.archived_at.is_(None),
             )
             )
+            .order_by(Spool.id)
         )
         )
-        if conflict.scalar_one_or_none():
-            raise HTTPException(409, "Tag UID already linked to another active spool")
+        holder = conflict.scalars().first()
+        if holder:
+            raise tag_already_linked("tag_uid", holder.id)
         # Auto-clear from archived spools (tag recycling)
         # Auto-clear from archived spools (tag recycling)
         archived_with_tag = await db.execute(
         archived_with_tag = await db.execute(
             select(Spool).where(
             select(Spool).where(
@@ -2120,14 +2168,17 @@ async def link_tag_to_spool(
 
 
     if normalized_tray_uuid:
     if normalized_tray_uuid:
         conflict = await db.execute(
         conflict = await db.execute(
-            select(Spool).where(
+            select(Spool)
+            .where(
                 func.upper(Spool.tray_uuid) == normalized_tray_uuid,
                 func.upper(Spool.tray_uuid) == normalized_tray_uuid,
                 Spool.id != spool_id,
                 Spool.id != spool_id,
                 Spool.archived_at.is_(None),
                 Spool.archived_at.is_(None),
             )
             )
+            .order_by(Spool.id)
         )
         )
-        if conflict.scalar_one_or_none():
-            raise HTTPException(409, "Tray UUID already linked to another active spool")
+        holder = conflict.scalars().first()
+        if holder:
+            raise tag_already_linked("tray_uuid", holder.id)
         archived_with_uuid = await db.execute(
         archived_with_uuid = await db.execute(
             select(Spool).where(
             select(Spool).where(
                 func.upper(Spool.tray_uuid) == normalized_tray_uuid,
                 func.upper(Spool.tray_uuid) == normalized_tray_uuid,

+ 172 - 164
backend/app/api/routes/library.py

@@ -1,5 +1,6 @@
 """API routes for File Manager (Library) functionality."""
 """API routes for File Manager (Library) functionality."""
 
 
+import asyncio
 import base64
 import base64
 import binascii
 import binascii
 import contextlib
 import contextlib
@@ -21,8 +22,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
+from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
+from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
 from backend.app.core.auth import (
 from backend.app.core.auth import (
-    RequireCameraStreamTokenIfAuthEnabled,
+    require_media_token_ownership,
     require_ownership_permission,
     require_ownership_permission,
     require_permission_if_auth_enabled,
     require_permission_if_auth_enabled,
 )
 )
@@ -33,6 +36,7 @@ from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.library import (
 from backend.app.schemas.library import (
@@ -86,6 +90,7 @@ from backend.app.utils.filename import (
     safe_path_component,
     safe_path_component,
     validate_print_filename,
     validate_print_filename,
 )
 )
+from backend.app.utils.printer_models import is_gcode_compatible
 from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
 from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
     carries_gcode,
     carries_gcode,
@@ -94,6 +99,7 @@ from backend.app.utils.threemf_tools import (
     extract_embedded_presets_from_3mf,
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
     extract_project_filaments_from_3mf,
+    sanitize_project_settings_sentinels,
     select_plate_gcode_name,
     select_plate_gcode_name,
     supports_enabled_in_config,
     supports_enabled_in_config,
 )
 )
@@ -2832,14 +2838,61 @@ async def add_files_to_queue(
 
 
     Only sliced files (.gcode or .gcode.3mf) can be added to the queue.
     Only sliced files (.gcode or .gcode.3mf) can be added to the queue.
     The archive will be created automatically when the print starts.
     The archive will be created automatically when the print starts.
+
+    A caller may name a printer or a target model for the whole batch; with
+    neither, each file is aimed at the model it says it was sliced for. The
+    gates are the ones ``POST /queue/`` applies to a single item, because an
+    item that reaches the scheduler through this route has to be as printable
+    as one that reaches it through that one (#3112).
     """
     """
     added: list[AddToQueueResult] = []
     added: list[AddToQueueResult] = []
     errors: list[AddToQueueError] = []
     errors: list[AddToQueueError] = []
 
 
+    # Batch-level targeting. Rejected outright rather than per file: the whole
+    # request names one destination, so a bad one is not a property of any
+    # single file and reporting it fourteen times would say nothing extra.
+    target_model_norm = normalize_model_name(request.target_model)
+    if request.printer_id is not None and target_model_norm:
+        raise HTTPException(400, "Cannot specify both printer_id and target_model")
+
+    if request.printer_id is not None:
+        printer_row = (await db.execute(select(Printer).where(Printer.id == request.printer_id))).scalar_one_or_none()
+        if not printer_row:
+            raise HTTPException(400, "Printer not found")
+
+    # Active printers of every model, read once, and only when the batch has no
+    # printer of its own -- with one named, neither the check below nor the
+    # inference in the loop consults it. The explicit target is validated for
+    # the same reason POST /queue/ validates: a model nobody owns is a queue
+    # item that waits forever. The inferred target reads the same set and
+    # silently declines when it finds nothing, because there, owning no such
+    # printer is the user's situation rather than their mistake -- the file
+    # still queues, as the unassigned row it has always been.
+    active_models: set[str] = set()
+    if request.printer_id is None:
+        active_models = {
+            model
+            for (model,) in (
+                await db.execute(select(Printer.model).where(Printer.is_active == True).distinct())  # noqa: E712
+            ).all()
+            if model
+        }
+        if target_model_norm and target_model_norm not in active_models:
+            raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+
     # Get all requested files
     # Get all requested files
     result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
     result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
     files = {f.id: f for f in result.scalars().all()}
     files = {f.id: f for f in result.scalars().all()}
 
 
+    # Ownership-scoped reads apply here as everywhere else in this module: a
+    # file the caller may not read is a file they may not print. Dropped from
+    # the map rather than refused by name, so the per-file error below is the
+    # same "File not found" an unknown id gets and the response says nothing
+    # about which ids exist. Ownerless rows need LIBRARY_READ_ALL, matching
+    # _ensure_library_file_visible.
+    if current_user is not None and not current_user.has_permission(Permission.LIBRARY_READ_ALL.value):
+        files = {fid: f for fid, f in files.items() if f.created_by_id == current_user.id}
+
     # Project attribution (#1897): a file queued from a project-linked folder
     # Project attribution (#1897): a file queued from a project-linked folder
     # inherits that project, so the resulting archive counts toward the
     # inherits that project, so the resulting archive counts toward the
     # project's progress. A file's own project link wins over its folder's.
     # project's progress. A file's own project link wins over its folder's.
@@ -2883,10 +2936,66 @@ async def add_files_to_queue(
                 )
                 )
                 continue
                 continue
 
 
+            # The Bambu SD card is FAT32/exFAT, so an illegal character 553s at
+            # upload time. POST /queue/ rejects those at queue time (#1540) and
+            # this route did not, which turned a nameable mistake into a print
+            # that failed hours later.
+            try:
+                validate_print_filename(lib_file.filename)
+            except InvalidFilenameError as e:
+                errors.append(AddToQueueError(file_id=file_id, filename=lib_file.filename, error=str(e)))
+                continue
+
+            # Where this file is aimed. An explicit printer wins; an explicit
+            # model applies to every file and has to be one this file can
+            # legally run on; with neither, the file's own declaration is used
+            # when some active printer answers to it.
+            item_printer_id = request.printer_id
+            item_target_model: str | None = None
+            if item_printer_id is None:
+                if target_model_norm:
+                    sliced_for = (lib_file.file_metadata or {}).get("sliced_for_model")
+                    if not is_gcode_compatible(sliced_for, target_model_norm):
+                        errors.append(
+                            AddToQueueError(
+                                file_id=file_id,
+                                filename=lib_file.filename,
+                                error=(
+                                    f"File was sliced for {sliced_for} and cannot be dispatched to "
+                                    f"{target_model_norm} printers"
+                                ),
+                            )
+                        )
+                        continue
+                    item_target_model = target_model_norm
+                else:
+                    inferred = resolve_variant_model(lib_file)
+                    item_target_model = inferred if inferred in active_models else None
+
+            # Filament the scheduler must match before handing a model-based
+            # item to hardware. Without it the item goes to whichever printer
+            # of that model is idle, whatever is loaded in it.
+            required_filament_types = None
+            if item_target_model:
+                # POST /queue/'s own extractor, borrowed rather than
+                # reimplemented: a second copy of this rule is a second thing
+                # to keep in step.
+                #
+                # Off the loop, unlike there: that route parses one 3MF per
+                # request and this one parses every file in the batch, so on a
+                # bulk add of a few hundred -- especially from an external
+                # folder on a NAS -- the zip reads add up to a stall the whole
+                # event loop takes, status ingest included.
+                filament_types = await asyncio.to_thread(_extract_filament_types_from_3mf, file_path)
+                if filament_types:
+                    required_filament_types = json.dumps(filament_types)
+
             # Create queue item referencing library file (archive created at print start)
             # Create queue item referencing library file (archive created at print start)
             max_position += 1
             max_position += 1
             queue_item = PrintQueueItem(
             queue_item = PrintQueueItem(
-                printer_id=None,  # Unassigned
+                printer_id=item_printer_id,
+                target_model=item_target_model,
+                required_filament_types=required_filament_types,
                 library_file_id=file_id,
                 library_file_id=file_id,
                 project_id=lib_file.project_id
                 project_id=lib_file.project_id
                 or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
                 or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
@@ -2913,6 +3022,20 @@ async def add_files_to_queue(
             logger.exception("Error adding file %s to queue", file_id)
             logger.exception("Error adding file %s to queue", file_id)
             errors.append(AddToQueueError(file_id=file_id, filename=lib_file.filename, error=str(e)))
             errors.append(AddToQueueError(file_id=file_id, filename=lib_file.filename, error=str(e)))
 
 
+    # Nothing queued and something to say about why. Returning 200 here is what
+    # made this look like a working call that quietly did nothing: a client that
+    # checks the status code sees success, and the reasons sit in a body it had
+    # no cause to read (#3112). Partial success stays 200 -- items really were
+    # created, and the per-file errors belong with them.
+    if not added and errors:
+        raise HTTPException(
+            400,
+            detail={
+                "message": "No files could be added to the queue.",
+                "errors": [e.model_dump() for e in errors],
+            },
+        )
+
     await db.commit()
     await db.commit()
 
 
     return AddToQueueResponse(added=added, errors=errors)
     return AddToQueueResponse(added=added, errors=errors)
@@ -3213,16 +3336,22 @@ async def get_library_file_plate_thumbnail(
     file_id: int,
     file_id: int,
     plate_index: int,
     plate_index: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
 ):
-    """Get the thumbnail image for a specific plate from a library file."""
+    """Get the thumbnail image for a specific plate from a library file.
+
+    Ownership-gated on the same terms as the file itself (#3025).
+    """
     from starlette.responses import Response
     from starlette.responses import Response
 
 
+    user, can_read_all = auth_result
     result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
-    lib_file = result.scalar_one_or_none()
-
-    if not lib_file:
-        raise HTTPException(status_code=404, detail="File not found")
+    lib_file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
 
 
     file_path = Path(app_settings.base_dir) / lib_file.file_path
     file_path = Path(app_settings.base_dir) / lib_file.file_path
     if not file_path.exists():
     if not file_path.exists():
@@ -3486,142 +3615,6 @@ async def get_library_file_filament_requirements(
     }
     }
 
 
 
 
-_STRIPPABLE_3MF_CONFIGS = frozenset(
-    {
-        # Settings dump used by --load-settings validation; the CLI tries to
-        # match its sentinel values (`prime_tower_brim_width: -1`, empty
-        # arrays) against the supplied profile and rejects out-of-range.
-        "Metadata/project_settings.config",
-        # Per-object settings overrides referencing the source plate's
-        # filament IDs / printer IDs. When the user picks a different
-        # printer / filament triplet, the IDs no longer resolve and the
-        # CLI exits non-zero on input validation.
-        "Metadata/model_settings.config",
-        # Slicer-version + plate-config + filament-mapping snapshot from
-        # the original slice. Includes the original printer model and
-        # filament references; mismatches against `--load-settings`
-        # consistently surfaced as `Slicer CLI failed (500)` for every
-        # 3MF in production. Removing it lets the CLI build a fresh slice
-        # plan from the supplied profile triplet.
-        "Metadata/slice_info.config",
-        # Multi-part / split-mesh metadata referencing object IDs from the
-        # original slice. Strip for the same reason — preserves the geometry
-        # in `3D/3dmodel.model` while dropping the orphan references.
-        "Metadata/cut_information.xml",
-    }
-)
-
-
-def _strip_3mf_embedded_settings(zip_bytes: bytes) -> bytes:
-    """Remove embedded slicer-config metadata from a 3MF.
-
-    Bambuddy supplies the slicer profile triplet via the sidecar's
-    ``--load-settings`` path; the 3MF's embedded settings would otherwise be
-    validated by the CLI first and can fail with sentinel-value range
-    checks (`prime_tower_brim_width: -1 not in range`, etc.) regardless of
-    what we pass via ``--load-settings``. Stripping the embedded configs
-    forces the CLI to use the supplied profiles only. Geometry
-    (``3D/3dmodel.model``), thumbnails, color, and multi-part data inside
-    the 3MF are preserved.
-
-    The set of strippable filenames is centralised in
-    ``_STRIPPABLE_3MF_CONFIGS`` — see that constant for the per-file
-    rationale. Project-settings alone wasn't enough: real-world Bambu
-    Studio 3MFs cross-reference printer / filament IDs from the other
-    metadata configs, and any single leftover triggered the validation
-    failure that made every profile-driven slice fall back to embedded
-    settings.
-    """
-    from io import BytesIO
-
-    src = BytesIO(zip_bytes)
-    dst = BytesIO()
-    with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
-        for item in zin.infolist():
-            if item.filename in _STRIPPABLE_3MF_CONFIGS:
-                continue
-            zout.writestr(item, zin.read(item.filename))
-    return dst.getvalue()
-
-
-# Keys in ``Metadata/project_settings.config`` that BambuStudio writes ``"-1"``
-# to when the user wants the value inherited from the parent process preset.
-# The CLI's ``StaticPrintConfig`` validator runs against the embedded settings
-# *before* ``--load-settings`` overrides apply, so a sentinel ``"-1"`` trips
-# the field's lower-bound range check and the CLI exits non-zero before our
-# profile triplet is ever consulted (#1201 — MakerWorld P2S models).
-#
-# Allowlisted (rather than "strip every '-1' value") because some fields
-# legitimately accept negative numbers (z_offset, translation values, etc.)
-# and a blanket strip would silently corrupt those.
-#
-# Add new entries here as more reports surface — the slicer's error message
-# names the offending field directly (`<field>: -1 not in range [...]`).
-_PROJECT_SETTINGS_SENTINEL_KEYS = frozenset(
-    {
-        # Reported in #1201 (MakerWorld P2S 3MFs).
-        "raft_first_layer_expansion",
-        "tree_support_wall_count",
-        # Cited in the strip-experiment comment block above as a known sentinel
-        # case from earlier reports.
-        "prime_tower_brim_width",
-    }
-)
-
-
-def _sanitize_project_settings_sentinels(zip_bytes: bytes) -> bytes:
-    """Strip ``"-1"`` inherit-from-parent sentinels from the 3MF's
-    ``Metadata/project_settings.config`` so the slicer CLI's range validator
-    accepts the file (#1201).
-
-    Removes only allowlisted keys (see ``_PROJECT_SETTINGS_SENTINEL_KEYS``)
-    when their value is exactly ``"-1"``. The rest of the config — and every
-    other entry in the zip — is preserved byte-for-byte. Unlike the earlier
-    full-strip experiment (see ``_strip_3mf_embedded_settings`` and the
-    cautionary comment in ``_run_slicer_with_fallback``) this leaves
-    ``StaticPrintConfig`` initialisation intact: the file is still present,
-    still parses, and the slicer falls back to the supplied
-    ``--load-settings`` value for the removed key.
-
-    Returns the original bytes unchanged when no sanitisation is needed
-    (input isn't a valid zip, no ``project_settings.config``, no allowlisted
-    sentinels present, or any other parse failure) so the caller can pass
-    the result on without further checks.
-    """
-    from io import BytesIO
-
-    try:
-        with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zin:
-            if "Metadata/project_settings.config" not in zin.namelist():
-                return zip_bytes
-            try:
-                config = json.loads(zin.read("Metadata/project_settings.config").decode("utf-8"))
-            except (json.JSONDecodeError, UnicodeDecodeError):
-                return zip_bytes
-            if not isinstance(config, dict):
-                return zip_bytes
-            removed = [key for key in _PROJECT_SETTINGS_SENTINEL_KEYS if config.get(key) == "-1"]
-            if not removed:
-                return zip_bytes
-            for key in removed:
-                config.pop(key, None)
-            patched = json.dumps(config)
-            logger.info(
-                "3MF sanitiser: removed sentinel '-1' for keys %s — slicer will use --load-settings defaults",
-                sorted(removed),
-            )
-            dst = BytesIO()
-            with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
-                for item in zin.infolist():
-                    if item.filename == "Metadata/project_settings.config":
-                        zout.writestr(item, patched)
-                    else:
-                        zout.writestr(item, zin.read(item.filename))
-            return dst.getvalue()
-    except (zipfile.BadZipFile, OSError):
-        return zip_bytes
-
-
 def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
 def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
     """Overwrite ``curr_bed_type`` in a process-profile JSON before forwarding
     """Overwrite ``curr_bed_type`` in a process-profile JSON before forwarding
     to the slicer sidecar.
     to the slicer sidecar.
@@ -4016,13 +4009,14 @@ async def _run_slicer_with_fallback(
     is_3mf = model_filename.lower().endswith(".3mf")
     is_3mf = model_filename.lower().endswith(".3mf")
     primary_bytes = model_bytes
     primary_bytes = model_bytes
     if is_3mf:
     if is_3mf:
-        # Strip "-1" inherit-from-parent sentinels from
-        # Metadata/project_settings.config so the CLI's StaticPrintConfig
-        # range validator accepts the file (#1201). Surgical — keeps the
-        # config present, just removes the offending keys; the supplied
-        # --load-settings (and the fallback's embedded values for keys we
-        # didn't touch) still drive the slice.
-        primary_bytes = _sanitize_project_settings_sentinels(primary_bytes)
+        # Strip inherit/unset sentinels from Metadata/project_settings.config
+        # so the CLI's StaticPrintConfig range validator accepts the file
+        # (#1201, #3030). Surgical — keeps the config present, just removes
+        # the offending keys; the supplied --load-settings (and the fallback's
+        # embedded values for keys we didn't touch) still drive the slice.
+        # The preview-slice path applies the same sanitiser in
+        # ``slice_preview.get_preview_filaments``.
+        primary_bytes = sanitize_project_settings_sentinels(primary_bytes)
 
 
         # #2622: the process settings the file's designer moved off the stock
         # #2622: the process settings the file's designer moved off the stock
         # preset. Read once — the support patch below needs to know which of
         # preset. Read once — the support patch below needs to know which of
@@ -4549,8 +4543,10 @@ async def slice_and_persist(
     # BS/Orca CLIs skip plate_N.png in headless --export-3mf — render +
     # BS/Orca CLIs skip plate_N.png in headless --export-3mf — render +
     # inject server-side so the library card has a thumbnail. Best-effort:
     # inject server-side so the library card has a thumbnail. Best-effort:
     # no-op when the slicer did embed thumbs (desktop Studio path), and
     # no-op when the slicer did embed thumbs (desktop Studio path), and
-    # falls through to the unmodified bytes on any render error.
-    result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))
+    # falls through to the unmodified bytes on any render error. In a thread:
+    # a large plate renders for seconds, and on the event loop that stalled
+    # every request and printer connection for as long (#3135).
+    result = result._replace(content=await asyncio.to_thread(inject_plate_thumbnails_if_missing, result.content))
     out_path.write_bytes(result.content)
     out_path.write_bytes(result.content)
 
 
     # Extract thumbnail from the produced 3MF so the library card shows a
     # Extract thumbnail from the produced 3MF so the library card shows a
@@ -4697,8 +4693,9 @@ async def slice_and_persist_as_archive(
     # See library-slice path: BS/Orca sidecar CLIs don't embed plate_N.png
     # See library-slice path: BS/Orca sidecar CLIs don't embed plate_N.png
     # in headless --export-3mf, so the produced 3MF often has no thumbnail
     # in headless --export-3mf, so the produced 3MF often has no thumbnail
     # at all. Server-side render fills the gap; no-op when the slicer did
     # at all. Server-side render fills the gap; no-op when the slicer did
-    # embed (desktop Studio path) and best-effort on any render error.
-    result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))
+    # embed (desktop Studio path) and best-effort on any render error. Off the
+    # event loop, like the library-slice path (#3135).
+    result = result._replace(content=await asyncio.to_thread(inject_plate_thumbnails_if_missing, result.content))
     out_path.write_bytes(result.content)
     out_path.write_bytes(result.content)
 
 
     # Extract a thumbnail for the new archive card. Priority order:
     # Extract a thumbnail for the new archive card. Priority order:
@@ -5264,13 +5261,15 @@ async def download_library_file_for_slicer(
 ):
 ):
     """Download a library file using a slicer download token.
     """Download a library file using a slicer download token.
 
 
-    Token-authenticated (no auth headers needed). The token is short-lived
-    and single-use, created by POST /files/{file_id}/slicer-token.
+    Token-authenticated (no auth headers needed). The token is short-lived and
+    file-bound, created by POST /files/{file_id}/slicer-token, and redeemable
+    for the rest of its TTL rather than exactly once -- the slicer is a separate
+    process that may fetch the URL more than once (#3029).
     Filename is at the end of the URL so slicers can detect the file format.
     Filename is at the end of the URL so slicers can detect the file format.
     """
     """
     from backend.app.core.auth import verify_slicer_download_token
     from backend.app.core.auth import verify_slicer_download_token
 
 
-    if not await verify_slicer_download_token(token, "library", file_id):
+    if not await verify_slicer_download_token(token, "library", file_id, single_use=False):
         raise HTTPException(status_code=403, detail="Invalid or expired download token")
         raise HTTPException(status_code=403, detail="Invalid or expired download token")
 
 
     result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
@@ -5293,14 +5292,23 @@ async def download_library_file_for_slicer(
 async def get_thumbnail(
 async def get_thumbnail(
     file_id: int,
     file_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
 ):
-    """Get a file's thumbnail."""
-    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
-    file = result.scalar_one_or_none()
+    """Get a file's thumbnail.
 
 
-    if not file:
-        raise HTTPException(status_code=404, detail="File not found")
+    Accepts a media token in ``?token=`` because <img> cannot send headers.
+    Ownership is enforced here rather than assumed from the credential: until
+    #3025 this route took the anonymous camera-stream token, which carried no
+    principal, so any holder could read any user's thumbnail by walking IDs.
+    """
+    user, can_read_all = auth_result
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
+    file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
 
 
     abs_thumb_path = to_absolute_path(file.thumbnail_path)
     abs_thumb_path = to_absolute_path(file.thumbnail_path)
     if not abs_thumb_path or not abs_thumb_path.exists():
     if not abs_thumb_path or not abs_thumb_path.exists():

+ 248 - 230
backend/app/api/routes/makerworld.py

@@ -1,13 +1,20 @@
 """MakerWorld integration routes.
 """MakerWorld integration routes.
 
 
-User pastes a MakerWorld URL → Bambuddy resolves it → shows plate list →
-one-click import/print. The URL-paste flow covers the actual discovery
-pattern (Reddit/YouTube/shared links) without needing to replicate
-MakerWorld's whole search UI.
+User pastes a model URL (MakerWorld or other supported host) → Bambuddy resolves
+it → shows plate list → one-click import/print. The URL-paste flow covers the
+actual discovery pattern (Reddit/YouTube/shared links) without needing to
+replicate the host's whole search UI.
 
 
 Search/browse endpoints are intentionally NOT exposed: the public-facing
 Search/browse endpoints are intentionally NOT exposed: the public-facing
 ``design/search`` endpoint returns empty results from server-originated
 ``design/search`` endpoint returns empty results from server-originated
 requests (see memory/makerworld-integration.md for the investigation).
 requests (see memory/makerworld-integration.md for the investigation).
+
+These are still the *MakerWorld* routes: they consult the shared seams where
+one exists — URL routing via :class:`ModelProviderRegistry`, permissions and
+folder naming from the provider descriptor, already-imported matching via
+:meth:`ModelProvider.source_url_filter` — but request/response shapes remain
+MakerWorld-specific. The fully shared import API that makes new hosts work
+with zero route changes arrives with #2793.
 """
 """
 
 
 from __future__ import annotations
 from __future__ import annotations
@@ -16,19 +23,20 @@ import logging
 import os
 import os
 from urllib.parse import unquote
 from urllib.parse import unquote
 
 
-from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi import APIRouter, Depends, Header, HTTPException, Query
 from fastapi.responses import Response
 from fastapi.responses import Response
+from fastapi.security import HTTPAuthorizationCredentials
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.api.routes.cloud import (
-    get_stored_token,
-    is_cloud_token_invalid,
-    mark_cloud_token_invalid,
-    resolve_api_key_cloud_owner,
-)
+from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.library import save_3mf_bytes_to_library
 from backend.app.api.routes.library import save_3mf_bytes_to_library
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    require_auth_if_enabled,
+    require_permission_if_auth_enabled,
+    security,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
@@ -41,74 +49,119 @@ from backend.app.schemas.makerworld import (
     MakerWorldResolveRequest,
     MakerWorldResolveRequest,
     MakerWorldStatus,
     MakerWorldStatus,
 )
 )
-from backend.app.services.makerworld import (
-    MakerWorldAuthError,
-    MakerWorldError,
-    MakerWorldForbiddenError,
-    MakerWorldNotFoundError,
-    MakerWorldService,
-    MakerWorldUnavailableError,
-    MakerWorldUrlError,
+from backend.app.services.model_providers import makerworld_provider, registry
+from backend.app.services.model_providers.base import (
+    ModelProvider,
+    ProviderAuthError,
+    ProviderError,
+    ProviderForbiddenError,
+    ProviderNotFoundError,
+    ProviderResourceRef,
+    ProviderService,
+    ProviderUnavailableError,
+    ProviderUrlError,
 )
 )
+from backend.app.services.model_providers.makerworld.service import MakerWorldService
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 router = APIRouter(prefix="/makerworld", tags=["makerworld"])
 router = APIRouter(prefix="/makerworld", tags=["makerworld"])
 
 
-_SOURCE_TYPE = "makerworld"
+
+def _provider_for_url(url: str) -> ModelProvider:
+    """Return the registered model provider that claims *url*.
+
+    A pasted link for an unsupported host is a clean 400 — the registry is
+    the routing seam, and "nobody supports this URL" is a client-input
+    problem, not a server error.
+    """
+    provider = registry.find_for_url(url)
+    if provider is None:
+        msg = f"No registered model provider supports {url!r}"
+        raise HTTPException(status_code=400, detail=msg)
+    return provider
 
 
 
 
-async def _build_service(db: AsyncSession, user: User | None) -> MakerWorldService:
-    """Construct a per-request MakerWorldService seeded with the caller's
-    stored Bambu Cloud bearer token when available.
+def _provider_for_source(source_type: str) -> ModelProvider:
+    """Return the registered model provider with this ``source_type``.
 
 
-    Mirrors ``cloud.build_authenticated_cloud`` — the token is entirely
-    optional; anonymous calls (metadata, URL resolution) still work — and,
-    like it, records a rejected token so the whole app agrees the sign-in is
-    dead rather than each feature failing on its own.
+    Import identifies a resource by numeric id, not by URL, so there is
+    nothing to route on except the source type the caller names. The detail
+    is built here rather than via ``str(KeyError)`` — KeyError's ``__str__``
+    is the *repr* of its argument and would ship the quotes to the client.
     """
     """
-    token, _email, _region = await get_stored_token(db, user)
-    user_id = user.id if user is not None else None
-    return MakerWorldService(
-        auth_token=token,
-        on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
-    )
+    try:
+        return registry.get(source_type)
+    except KeyError as exc:
+        msg = f"No model provider registered for source_type {source_type!r}"
+        raise HTTPException(status_code=400, detail=msg) from exc
+
+
+async def _authorize_for_provider(
+    provider: ModelProvider,
+    permission: Permission | None,
+    credentials: HTTPAuthorizationCredentials | None,
+    x_api_key: str | None,
+) -> User | None:
+    """Apply *provider*'s own permission to a request that named it.
+
+    This cannot live in the route signature. FastAPI resolves dependencies
+    before the body exists, so a dependency can only ever bake in one
+    provider's permission — MakerWorld's — while the provider actually being
+    used comes from the request (``source_type`` on import, the pasted URL on
+    resolve). Importing from a second provider would then be gated on
+    ``makerworld:import``, which is nobody's intent.
+
+    The check runs through the same ``require_permission_if_auth_enabled``
+    the decorator would have built, so JWT users, API keys (scope gate plus
+    the owner-outranks-key rule) and auth-disabled installs behave exactly as
+    before. The routes keep a permission-free ``require_auth_if_enabled``
+    dependency so an anonymous caller is still refused before the body is
+    read.
+
+    A provider that declares no permission is refused rather than waved
+    through: the descriptor's permission fields are optional, and "unset"
+    must not read as "unrestricted".
+    """
+    if permission is None:
+        raise HTTPException(
+            status_code=500,
+            detail=f"Model provider {provider.source_type!r} declares no permission for this operation",
+        )
+    checker = require_permission_if_auth_enabled(permission)
+    return await checker(credentials=credentials, x_api_key=x_api_key)
 
 
 
 
-def _canonical_url(model_id: int, profile_id: int | None = None) -> str:
-    """Build a stable source_url we use for dedupe.
+async def _build_service(
+    db: AsyncSession,
+    provider: ModelProvider,
+    current_user: User | None,
+    api_key_cloud_owner: User | None = None,
+) -> ProviderService:
+    """Construct a per-request service via *provider*.
 
 
-    Dedupe is keyed per *plate* (profile) rather than per model, since the
-    ``/iot-service/.../profile/{profileId}`` download returns a specific
-    plate — not the full multi-plate zip — so two different plates of the
-    same design should become two separate library entries. Canonical
-    shape uses the locale-free path with the ``#profileId-`` fragment so
-    all URL variants of the same plate still collapse (e.g. ``/en/models/
-    123-slug?from=search#profileId-456`` and ``/de/models/123#profileId-
-    456`` both map to ``https://makerworld.com/models/123#profileId-
-    456``). Plate-less imports (legacy or whole-design) keep the old
-    model-only shape for backwards compatibility with existing rows.
+    Identity resolution (JWT user vs API-key owner vs anonymous) and
+    credential seeding live inside ``provider.build_service`` — the single
+    place every provider resolves them, so the routes never re-implement it.
     """
     """
-    if profile_id:
-        return f"https://makerworld.com/models/{model_id}#profileId-{profile_id}"
-    return f"https://makerworld.com/models/{model_id}"
+    return await provider.build_service(db=db, user=current_user, api_key_owner=api_key_cloud_owner)
 
 
 
 
-def _map_service_error(exc: MakerWorldError) -> HTTPException:
-    """Translate service exceptions into HTTP responses."""
-    if isinstance(exc, MakerWorldUrlError):
+def _map_service_error(exc: ProviderError) -> HTTPException:
+    """Translate provider service exceptions into HTTP responses."""
+    if isinstance(exc, ProviderUrlError):
         return HTTPException(status_code=400, detail=str(exc))
         return HTTPException(status_code=400, detail=str(exc))
-    if isinstance(exc, MakerWorldAuthError):
+    if isinstance(exc, ProviderAuthError):
         return HTTPException(status_code=401, detail=str(exc))
         return HTTPException(status_code=401, detail=str(exc))
-    if isinstance(exc, MakerWorldForbiddenError):
-        # 403 forwards MakerWorld's own refusal message (content-gated,
+    if isinstance(exc, ProviderForbiddenError):
+        # 403 forwards the provider's own refusal message (content-gated,
         # region-locked, requires points, etc.) — UI surfaces it verbatim.
         # region-locked, requires points, etc.) — UI surfaces it verbatim.
         return HTTPException(status_code=403, detail=str(exc))
         return HTTPException(status_code=403, detail=str(exc))
-    if isinstance(exc, MakerWorldNotFoundError):
+    if isinstance(exc, ProviderNotFoundError):
         return HTTPException(status_code=404, detail=str(exc))
         return HTTPException(status_code=404, detail=str(exc))
-    if isinstance(exc, MakerWorldUnavailableError):
+    if isinstance(exc, ProviderUnavailableError):
         return HTTPException(status_code=502, detail=str(exc))
         return HTTPException(status_code=502, detail=str(exc))
-    return HTTPException(status_code=500, detail=f"MakerWorld error: {exc}")
+    return HTTPException(status_code=500, detail=f"Model provider error: {exc}")
 
 
 
 
 @router.get("/thumbnail")
 @router.get("/thumbnail")
@@ -133,10 +186,10 @@ async def proxy_thumbnail(
     URLs are content-addressable (filename contains a hash), so the
     URLs are content-addressable (filename contains a hash), so the
     aggressive ``immutable`` cache-control is safe.
     aggressive ``immutable`` cache-control is safe.
     """
     """
-    service = MakerWorldService()
+    service = MakerWorldService(thumbnail_hosts=makerworld_provider.thumbnail_hosts())
     try:
     try:
         payload, content_type = await service.fetch_thumbnail(url)
         payload, content_type = await service.fetch_thumbnail(url)
-    except MakerWorldError as exc:
+    except ProviderError as exc:
         raise _map_service_error(exc) from exc
         raise _map_service_error(exc) from exc
     finally:
     finally:
         await service.close()
         await service.close()
@@ -153,7 +206,7 @@ async def proxy_thumbnail(
 @router.get("/status", response_model=MakerWorldStatus)
 @router.get("/status", response_model=MakerWorldStatus)
 async def get_status(
 async def get_status(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
+    current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ):
 ):
     """Report whether the caller can import 3MFs (needs a Bambu Cloud token).
     """Report whether the caller can import 3MFs (needs a Bambu Cloud token).
@@ -164,26 +217,37 @@ async def get_status(
     stored token rather than always reporting ``False`` (#1777, same shape
     stored token rather than always reporting ``False`` (#1777, same shape
     as the cloud-presets fix in #1182).
     as the cloud-presets fix in #1182).
     """
     """
-    cloud_token_user = current_user or api_key_cloud_owner
-    token, _email, _region = await get_stored_token(db, cloud_token_user)
-    has_token = bool(token)
-    # A token Bambu has already rejected downloads nothing. ``can_download``
-    # used to be a bare alias for ``has_cloud_token``, so the import button
-    # stayed enabled against a dead credential and the user found out via a
-    # 401 toast (#2562 follow-up).
-    expired = has_token and await is_cloud_token_invalid(db, cloud_token_user)
+    service = await _build_service(db, makerworld_provider, current_user, api_key_cloud_owner)
+    try:
+        status = await service.get_status(db)
+    finally:
+        await service.close()
     return MakerWorldStatus(
     return MakerWorldStatus(
-        has_cloud_token=has_token,
-        can_download=has_token and not expired,
-        sign_in_expired=expired,
+        has_cloud_token=status.authenticated,
+        can_download=status.can_download,
+        # ``credential_rejected`` is the machine-readable "your sign-in
+        # expired" state the provider set exactly when a stored token exists
+        # *and* was rejected — no token means there is no sign-in to have
+        # expired. It is read instead of ``auth_error is not None`` because
+        # the latter is a human-readable reason that providers may also set
+        # for non-credential failures (network, rate limit).
+        sign_in_expired=status.credential_rejected,
     )
     )
 
 
 
 
-@router.post("/resolve", response_model=MakerWorldResolvedModel)
+@router.post(
+    "/resolve",
+    response_model=MakerWorldResolvedModel,
+    # Authentication only — the permission belongs to whichever provider the
+    # pasted URL routes to, which is not known until the body is parsed (see
+    # ``_authorize_for_provider``).
+    dependencies=[Depends(require_auth_if_enabled)],
+)
 async def resolve_url(
 async def resolve_url(
     body: MakerWorldResolveRequest,
     body: MakerWorldResolveRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
+    credentials: HTTPAuthorizationCredentials | None = Depends(security),
+    x_api_key: str | None = Header(default=None, alias="X-API-Key"),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ):
 ):
     """Resolve a MakerWorld URL to full model metadata + plate list.
     """Resolve a MakerWorld URL to full model metadata + plate list.
@@ -192,68 +256,34 @@ async def resolve_url(
     exist for the same model URL, so the UI can show an "Already imported"
     exist for the same model URL, so the UI can show an "Already imported"
     badge and skip a redundant download.
     badge and skip a redundant download.
     """
     """
+    # Strategy pattern: select provider based on URL instead of hardcoding.
+    # Routing runs before the permission check because the permission *is* the
+    # provider's; all an unpermitted caller learns from the ordering is which
+    # hosts Bambuddy supports, which the UI states anyway.
+    provider = _provider_for_url(body.url)
+    current_user = await _authorize_for_provider(provider, provider.view_permission, credentials, x_api_key)
     try:
     try:
-        model_id, profile_id = MakerWorldService.parse_url(body.url)
-    except MakerWorldError as exc:
+        ref = provider.parse_url(body.url)
+    except ProviderError as exc:
         raise _map_service_error(exc) from exc
         raise _map_service_error(exc) from exc
+    model_id = int(ref.external_id)
+    profile_id = int(ref.sub_id) if ref.sub_id else None
 
 
-    # API-keyed callers carry identity on the key, not in current_user — see
-    # the /status handler comment and #1777 / #1182.
-    cloud_token_user = current_user or api_key_cloud_owner
-    service = await _build_service(db, cloud_token_user)
+    service = await _build_service(db, provider, current_user, api_key_cloud_owner)
     try:
     try:
-        design = await service.get_design(model_id)
-        instances_envelope = await service.get_design_instances(model_id)
-    except MakerWorldError as exc:
+        resolved = await service.resolve(ref)
+    except ProviderError as exc:
         raise _map_service_error(exc) from exc
         raise _map_service_error(exc) from exc
     finally:
     finally:
         await service.close()
         await service.close()
 
 
-    # MakerWorld's instances payload is ``{"total": N, "hits": [...]}``; callers
-    # only care about the hits, and we normalise the null case to an empty list
-    # so the frontend doesn't have to handle null vs [] both ways.
-    instances = instances_envelope.get("hits") or []
-    if not isinstance(instances, list):
-        instances = []
-
-    # /instances/hits omits the per-instance printer compatibility info that
-    # /design.instances[].extention.modelInfo carries (compatibility +
-    # otherCompatibility). Merge it in so the frontend can show "this
-    # instance was sliced for A1" + "also marked compatible with: H2D, P1S,
-    # …" before the user picks one — without that, every instance row looks
-    # identical in the UI and users blindly pick the first one regardless of
-    # whether it matches their printer.
-    design_instances = design.get("instances") or []
-    if isinstance(design_instances, list):
-        compat_by_id = {}
-        for di in design_instances:
-            if not isinstance(di, dict):
-                continue
-            iid = di.get("id")
-            if iid is None:
-                continue
-            ext = (di.get("extention") or {}).get("modelInfo") or {}
-            compat_by_id[iid] = {
-                "compatibility": ext.get("compatibility"),
-                "otherCompatibility": ext.get("otherCompatibility"),
-            }
-        for inst in instances:
-            if not isinstance(inst, dict):
-                continue
-            iid = inst.get("id")
-            extra = compat_by_id.get(iid)
-            if extra:
-                inst["compatibility"] = extra["compatibility"]
-                inst["otherCompatibility"] = extra["otherCompatibility"]
-
-    # Find every library row whose source_url is either the model-level
-    # canonical URL (legacy whole-model imports) or any plate-level URL
-    # (``...#profileId-{n}``) under this model. The frontend surfaces this
+    # Find every library row whose source_url belongs to this resource —
+    # the provider's :meth:`source_url_filter` owns what "belongs" means
+    # (whole-model key, per-plate keys, ...). The frontend surfaces the ids
     # to mark imported plates in the instance picker.
     # to mark imported plates in the instance picker.
-    model_prefix = _canonical_url(model_id)
     existing_q = await db.execute(
     existing_q = await db.execute(
         select(LibraryFile.id).where(
         select(LibraryFile.id).where(
-            (LibraryFile.source_url == model_prefix) | (LibraryFile.source_url.like(f"{model_prefix}#profileId-%")),
+            provider.source_url_filter(LibraryFile.source_url, str(model_id)),
             LibraryFile.deleted_at.is_(None),
             LibraryFile.deleted_at.is_(None),
         )
         )
     )
     )
@@ -262,17 +292,24 @@ async def resolve_url(
     return MakerWorldResolvedModel(
     return MakerWorldResolvedModel(
         model_id=model_id,
         model_id=model_id,
         profile_id=profile_id,
         profile_id=profile_id,
-        design=design,
-        instances=instances,
+        design=resolved.design,
+        instances=resolved.instances,
         already_imported_library_ids=already_imported,
         already_imported_library_ids=already_imported,
     )
     )
 
 
 
 
-@router.post("/import", response_model=MakerWorldImportResponse)
+@router.post(
+    "/import",
+    response_model=MakerWorldImportResponse,
+    # Authentication only — the permission belongs to the provider named by
+    # ``source_type`` (see ``_authorize_for_provider``).
+    dependencies=[Depends(require_auth_if_enabled)],
+)
 async def import_instance(
 async def import_instance(
     body: MakerWorldImportRequest,
     body: MakerWorldImportRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_IMPORT),
+    credentials: HTTPAuthorizationCredentials | None = Depends(security),
+    x_api_key: str | None = Header(default=None, alias="X-API-Key"),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ):
 ):
     """Download a specific MakerWorld instance (plate configuration) and save
     """Download a specific MakerWorld instance (plate configuration) and save
@@ -282,6 +319,15 @@ async def import_instance(
     was imported before (any plate), that existing LibraryFile is returned and
     was imported before (any plate), that existing LibraryFile is returned and
     no new download happens.
     no new download happens.
     """
     """
+    # Resolve the provider first: an unknown ``source_type`` must 400 before
+    # the default-destination folder gets auto-created as a side effect — and
+    # the permission that applies is the resolved provider's, not MakerWorld's,
+    # so it cannot be checked any earlier. All that costs is telling an
+    # authenticated-but-unpermitted caller which source types are registered,
+    # which the UI lists anyway; anonymous callers never get this far.
+    provider = _provider_for_source(body.source_type)
+    current_user = await _authorize_for_provider(provider, provider.import_permission, credentials, x_api_key)
+
     if body.folder_id is not None:
     if body.folder_id is not None:
         folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == body.folder_id))
         folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == body.folder_id))
         target_folder = folder_q.scalar_one_or_none()
         target_folder = folder_q.scalar_one_or_none()
@@ -294,90 +340,78 @@ async def import_instance(
             )
             )
         effective_folder_id: int | None = body.folder_id
         effective_folder_id: int | None = body.folder_id
     else:
     else:
-        # Default destination: a dedicated top-level "MakerWorld" folder. Keeps
-        # imports out of the library root so power users can still organise
-        # manually in subfolders, and auto-creates the folder on the first
-        # import so users don't have to set it up themselves.
-        mw_folder_q = await db.execute(
-            select(LibraryFolder).where(
-                LibraryFolder.name == "MakerWorld",
-                LibraryFolder.parent_id.is_(None),
-                LibraryFolder.is_external.is_(False),
+        # Default destination: the resolved provider's dedicated top-level
+        # folder (``default_folder_name`` — read off *provider*, not the
+        # MakerWorld singleton, so the second provider lands in its own
+        # folder). Keeps imports out of the library root so power users can
+        # still organise manually in subfolders, and auto-creates the folder
+        # on the first import so users don't have to set it up themselves. A
+        # provider that leaves it unset imports into the library root rather
+        # than minting a NULL-named folder.
+        default_folder_name = provider.default_folder_name
+        if default_folder_name is None:
+            effective_folder_id = None
+        else:
+            default_folder_q = await db.execute(
+                select(LibraryFolder).where(
+                    LibraryFolder.name == default_folder_name,
+                    LibraryFolder.parent_id.is_(None),
+                    LibraryFolder.is_external.is_(False),
+                )
             )
             )
-        )
-        mw_folder = mw_folder_q.scalar_one_or_none()
-        if mw_folder is None:
-            mw_folder = LibraryFolder(name="MakerWorld", parent_id=None)
-            db.add(mw_folder)
-            await db.flush()
-        effective_folder_id = mw_folder.id
-
-    # API-keyed callers carry identity on the key, not in current_user — see
-    # the /status handler comment and #1777 / #1182. The same resolved user
-    # is reused for owner_id on save_3mf_bytes_to_library below so the
-    # library row is attributed to the key's owner rather than NULL.
-    cloud_token_user = current_user or api_key_cloud_owner
-    service = await _build_service(db, cloud_token_user)
+            default_folder = default_folder_q.scalar_one_or_none()
+            if default_folder is None:
+                default_folder = LibraryFolder(name=default_folder_name, parent_id=None)
+                db.add(default_folder)
+                await db.flush()
+            effective_folder_id = default_folder.id
+
+    service = await _build_service(db, provider, current_user, api_key_cloud_owner)
 
 
     # YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
     # YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
-    # (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N}.
-    # Fetch design metadata to resolve it, and — in the same call — pick a
-    # default profileId from the response if the frontend didn't specify one.
+    # (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.
+    ref = ProviderResourceRef(
+        source_type=provider.source_type,
+        external_id=str(body.model_id),
+        sub_id=str(body.profile_id) if body.profile_id else None,
+    )
+
     try:
     try:
-        design = await service.get_design(body.model_id)
-    except MakerWorldError as exc:
-        await service.close()
-        raise _map_service_error(exc) from exc
+        info = await service.get_download(ref)
+        # The provider enriches ``sub_id`` with the actually-resolved profile
+        # when the caller omitted one.
+        resolved_profile_id = int(info.ref.sub_id) if info.ref.sub_id else None
 
 
-    alphanumeric_model_id = design.get("modelId")
-    if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
-        await service.close()
-        raise HTTPException(
-            status_code=502,
-            detail="MakerWorld design metadata missing the modelId field",
-        )
+        # Canonical URL includes profile_id so each plate gets its own library
+        # entry (see ``ModelProvider.canonical_url``).
+        source_url = provider.canonical_url(info.ref)
 
 
-    profile_id = body.profile_id
-    if profile_id is None:
-        for instance in design.get("instances") or []:
-            pid = instance.get("profileId")
-            if isinstance(pid, int) and pid > 0:
-                profile_id = pid
-                break
-        if profile_id is None:
-            try:
-                envelope = await service.get_design_instances(body.model_id)
-            except MakerWorldError as exc:
-                await service.close()
-                raise _map_service_error(exc) from exc
-            for hit in envelope.get("hits") or []:
-                pid = hit.get("profileId")
-                if isinstance(pid, int) and pid > 0:
-                    profile_id = pid
-                    break
-        if profile_id is None:
-            await service.close()
-            raise HTTPException(
-                status_code=502,
-                detail="MakerWorld returned no instances for this model",
+        # Dedupe check upfront so we don't burn bandwidth re-downloading.
+        existing_q = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
+        existing_row = existing_q.scalar_one_or_none()
+        if existing_row is not None:
+            return MakerWorldImportResponse(
+                library_file_id=existing_row.id,
+                filename=existing_row.filename,
+                folder_id=existing_row.folder_id,
+                profile_id=resolved_profile_id,
+                was_existing=True,
             )
             )
 
 
-    # Canonical URL includes profile_id so each plate gets its own library
-    # entry (see ``_canonical_url`` docstring).
-    source_url = _canonical_url(body.model_id, profile_id)
-
-    try:
-        manifest = await service.get_profile_download(profile_id, alphanumeric_model_id)
-    except MakerWorldError as exc:
-        await service.close()
+        download = await service.download(info)
+    except ProviderError as exc:
         raise _map_service_error(exc) from exc
         raise _map_service_error(exc) from exc
+    finally:
+        await service.close()
 
 
-    signed_url = manifest.get("url")
     # Basename-strip any path components from the upstream filename so a
     # Basename-strip any path components from the upstream filename so a
     # malicious response (``name: "../../evil.3mf"``) can't persist a suspect
     # malicious response (``name: "../../evil.3mf"``) can't persist a suspect
     # string into the library row or the UI. On-disk storage uses a UUID
     # string into the library row or the UI. On-disk storage uses a UUID
     # filename regardless (see library.py), so this is defence-in-depth.
     # filename regardless (see library.py), so this is defence-in-depth.
-    raw_name = manifest.get("name")
+    raw_name = info.suggested_filename
     if isinstance(raw_name, str) and raw_name.strip():
     if isinstance(raw_name, str) and raw_name.strip():
         # MakerWorld emits percent-encoded names (`%20` for spaces, etc.)
         # MakerWorld emits percent-encoded names (`%20` for spaces, etc.)
         # because the same string round-trips through HTTP URLs in the
         # because the same string round-trips through HTTP URLs in the
@@ -387,44 +421,24 @@ async def import_instance(
         suggested_name = os.path.basename(unquote(raw_name.strip())) or f"makerworld-{body.model_id}.3mf"
         suggested_name = os.path.basename(unquote(raw_name.strip())) or f"makerworld-{body.model_id}.3mf"
     else:
     else:
         suggested_name = f"makerworld-{body.model_id}.3mf"
         suggested_name = f"makerworld-{body.model_id}.3mf"
-    if not signed_url or not isinstance(signed_url, str):
-        await service.close()
-        raise HTTPException(status_code=502, detail="MakerWorld did not return a download URL")
-
-    # Dedupe check upfront so we don't burn bandwidth re-downloading.
-    if source_url:
-        existing_q = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
-        existing_row = existing_q.scalar_one_or_none()
-        if existing_row is not None:
-            await service.close()
-            return MakerWorldImportResponse(
-                library_file_id=existing_row.id,
-                filename=existing_row.filename,
-                folder_id=existing_row.folder_id,
-                profile_id=profile_id,
-                was_existing=True,
-            )
-
-    try:
-        file_bytes, download_filename = await service.download_3mf(signed_url)
-    except MakerWorldError as exc:
-        await service.close()
-        raise _map_service_error(exc) from exc
-    finally:
-        await service.close()
 
 
     # Prefer the server-provided human-readable filename; the signed URL's
     # Prefer the server-provided human-readable filename; the signed URL's
     # path ends in a UUID that's not meaningful to users. Decode the
     # path ends in a UUID that's not meaningful to users. Decode the
     # fallback path-tail too — same percent-encoding round-trip applies
     # fallback path-tail too — same percent-encoding round-trip applies
     # there as on the manifest-supplied name.
     # there as on the manifest-supplied name.
-    filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download_filename)
+    filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download.filename)
 
 
+    # API-keyed callers carry identity on the key, not in current_user (#1777);
+    # this collapse stays route-side solely so the library row is attributed
+    # to the key's owner rather than NULL. Credential identity is resolved
+    # inside the provider.
+    cloud_token_user = current_user or api_key_cloud_owner
     library_file, was_existing = await save_3mf_bytes_to_library(
     library_file, was_existing = await save_3mf_bytes_to_library(
         db,
         db,
-        file_bytes=file_bytes,
+        file_bytes=download.file_bytes,
         filename=filename,
         filename=filename,
         folder_id=effective_folder_id,
         folder_id=effective_folder_id,
-        source_type=_SOURCE_TYPE,
+        source_type=provider.source_type,
         source_url=source_url,
         source_url=source_url,
         owner_id=cloud_token_user.id if cloud_token_user else None,
         owner_id=cloud_token_user.id if cloud_token_user else None,
     )
     )
@@ -433,7 +447,7 @@ async def import_instance(
         library_file_id=library_file.id,
         library_file_id=library_file.id,
         filename=library_file.filename,
         filename=library_file.filename,
         folder_id=library_file.folder_id,
         folder_id=library_file.folder_id,
-        profile_id=profile_id,
+        profile_id=resolved_profile_id,
         was_existing=was_existing,
         was_existing=was_existing,
     )
     )
 
 
@@ -442,23 +456,27 @@ async def import_instance(
 async def recent_imports(
 async def recent_imports(
     limit: int = 10,
     limit: int = 10,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
+    current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
 ):
 ):
     """Last N MakerWorld imports, newest first.
     """Last N MakerWorld imports, newest first.
 
 
     Surfaces files whose ``source_type`` is ``"makerworld"`` so the MakerWorld
     Surfaces files whose ``source_type`` is ``"makerworld"`` so the MakerWorld
     page can show a 'Recent imports' sidebar that persists across resolves.
     page can show a 'Recent imports' sidebar that persists across resolves.
+    Widening this to all registered providers is a behaviour change that
+    belongs with the provider that needs it.
     ``limit`` is clamped to ``[1, 50]`` to keep payloads sensible.
     ``limit`` is clamped to ``[1, 50]`` to keep payloads sensible.
     """
     """
     _ = current_user  # permission gate only
     _ = current_user  # permission gate only
     capped = max(1, min(50, int(limit)))
     capped = max(1, min(50, int(limit)))
+
     result = await db.execute(
     result = await db.execute(
         LibraryFile.active()
         LibraryFile.active()
-        .where(LibraryFile.source_type == _SOURCE_TYPE)
+        .where(LibraryFile.source_type == makerworld_provider.source_type)
         .order_by(LibraryFile.created_at.desc())
         .order_by(LibraryFile.created_at.desc())
         .limit(capped)
         .limit(capped)
     )
     )
     rows = result.scalars().all()
     rows = result.scalars().all()
+
     return [
     return [
         MakerWorldRecentImport(
         MakerWorldRecentImport(
             library_file_id=row.id,
             library_file_id=row.id,

+ 40 - 22
backend/app/api/routes/orca_cloud.py

@@ -464,29 +464,47 @@ async def _build_authenticated_service(
         raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
         raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
 
 
     svc = OrcaCloudService()
     svc = OrcaCloudService()
-    svc.set_tokens(creds.token, creds.refresh_token, creds.expires_at)
-    if not svc.is_authenticated:
-        if not svc.refresh_token:
-            raise HTTPException(
-                status_code=401,
-                detail="Orca Cloud session expired and no refresh token is stored — sign in again.",
-            )
+    # The service owns an httpx client from construction, and every path below
+    # this point can raise. On success the caller closes it; on failure nobody
+    # ever holds it, so it has to be closed here or the connection pool leaks
+    # one client per failed build. That went unnoticed while the only callers
+    # were routes -- a person retrying a broken sign-in a few times -- and
+    # became worth fixing once spool assignment started building one too.
+    try:
+        svc.set_tokens(creds.token, creds.refresh_token, creds.expires_at)
+        if not svc.is_authenticated:
+            if not svc.refresh_token:
+                raise HTTPException(
+                    status_code=401,
+                    detail="Orca Cloud session expired and no refresh token is stored — sign in again.",
+                )
+            try:
+                await svc.refresh()
+            except OrcaCloudAuthError as e:
+                # Refresh token was revoked or rotated out from under us. Clear
+                # the stale credentials so the UI flips to disconnected — unless
+                # the caller is a background job, which must not change sign-in
+                # state on its own.
+                if clear_on_auth_failure:
+                    await _clear_credentials(db, user)
+                raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
+            except OrcaCloudError as e:
+                raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
+            # Persist new pair BEFORE returning. A crash between here and the
+            # downstream API call would still leave the user with valid stored
+            # tokens for the next request.
+            await _persist_rotated_tokens(db, user, svc.access_token, svc.refresh_token, svc.token_expiry)
+    except BaseException:
+        # BaseException, not Exception: a cancelled request leaks the client
+        # just as surely as a failed refresh does. The close is guarded in turn
+        # because failing to clean up must not replace the error the caller
+        # needs to see -- least of all a CancelledError, which has to keep
+        # propagating for cancellation to work at all.
         try:
         try:
-            await svc.refresh()
-        except OrcaCloudAuthError as e:
-            # Refresh token was revoked or rotated out from under us. Clear
-            # the stale credentials so the UI flips to disconnected — unless
-            # the caller is a background job, which must not change sign-in
-            # state on its own.
-            if clear_on_auth_failure:
-                await _clear_credentials(db, user)
-            raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
-        except OrcaCloudError as e:
-            raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
-        # Persist new pair BEFORE returning. A crash between here and the
-        # downstream API call would still leave the user with valid stored
-        # tokens for the next request.
-        await _persist_rotated_tokens(db, user, svc.access_token, svc.refresh_token, svc.token_expiry)
+            await svc.close()
+        except Exception as close_err:  # noqa: BLE001 - cleanup is best-effort
+            logger.debug("Orca Cloud client close failed while unwinding a failed build: %s", close_err)
+        raise
     return svc
     return svc
 
 
 
 

+ 14 - 3
backend/app/api/routes/print_log.py

@@ -7,8 +7,8 @@ from sqlalchemy import delete, func, nullslast, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
-    RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    require_media_token_ownership,
     require_ownership_permission,
     require_ownership_permission,
 )
 )
 from backend.app.core.config import settings
 from backend.app.core.config import settings
@@ -136,11 +136,19 @@ async def get_print_log(
 async def get_print_log_thumbnail(
 async def get_print_log_thumbnail(
     entry_id: int,
     entry_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
 ):
     """Get the thumbnail for a print log entry.
     """Get the thumbnail for a print log entry.
 
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    is scoped to the rows the caller can see in the log itself (#3025) -- the
+    same ``created_by_id`` filter ``get_print_log`` applies, including its
+    treatment of an ownerless entry as not-yours.
 
 
     Self-heals stale entries: when thumbnail_path points to a file that no
     Self-heals stale entries: when thumbnail_path points to a file that no
     longer exists on disk (archive was deleted, or print failed before the
     longer exists on disk (archive was deleted, or print failed before the
@@ -149,9 +157,12 @@ async def get_print_log_thumbnail(
     gated on entry.thumbnail_path being truthy, so the next fetch of the
     gated on entry.thumbnail_path being truthy, so the next fetch of the
     log list will simply not request this thumbnail again.
     log list will simply not request this thumbnail again.
     """
     """
+    user, can_read_all = auth_result
     entry = await db.get(PrintLogEntry, entry_id)
     entry = await db.get(PrintLogEntry, entry_id)
     if not entry or not entry.thumbnail_path:
     if not entry or not entry.thumbnail_path:
         raise HTTPException(404, "Thumbnail not found")
         raise HTTPException(404, "Thumbnail not found")
+    if not can_read_all and (user is None or entry.created_by_id != user.id):
+        raise HTTPException(404, "Thumbnail not found")
 
 
     thumb_path = settings.base_dir / entry.thumbnail_path
     thumb_path = settings.base_dir / entry.thumbnail_path
     if not thumb_path.exists():
     if not thumb_path.exists():

+ 24 - 10
backend/app/api/routes/print_queue.py

@@ -906,29 +906,34 @@ async def add_to_queue(
     # Extract filament types for model-based assignment (used by scheduler for validation)
     # Extract filament types for model-based assignment (used by scheduler for validation)
     required_filament_types = None
     required_filament_types = None
     file_path = None
     file_path = None
+    # Get file path from archive or library file
+    if archive:
+        file_path = settings.base_dir / archive.file_path
+    elif library_file:
+        lib_path = Path(library_file.file_path)
+        file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
     if target_model_norm:
     if target_model_norm:
-        # Get file path from archive or library file
-        if archive:
-            file_path = settings.base_dir / archive.file_path
-        elif library_file:
-            lib_path = Path(library_file.file_path)
-            file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
-
         if file_path and file_path.exists():
         if file_path and file_path.exists():
             filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
             filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
             if filament_types:
             if filament_types:
                 required_filament_types = json.dumps(filament_types)
                 required_filament_types = json.dumps(filament_types)
                 logger.info("Extracted filament types for model-based queue: %s", filament_types)
                 logger.info("Extracted filament types for model-based queue: %s", filament_types)
 
 
-    # If filament overrides are provided, update required_filament_types to match override types
+    # If filament overrides are provided, update required_filament_types to match override types.
+    # A specific-printer job keeps its overrides too (#3133): an override chosen for
+    # "Any P2S" survives the switch to one P2S in the print dialog, and when the
+    # dialog could not resolve every tray the scheduler recomputes the mapping at
+    # dispatch — against the 3MF's filament, unless the row still says otherwise.
+    # The type list below stays model-only; it gates which printer of a model is
+    # eligible, which a printer-targeted job has already settled.
     filament_overrides_json = None
     filament_overrides_json = None
-    if data.filament_overrides and target_model_norm:
+    if data.filament_overrides and (target_model_norm or data.printer_id is not None):
         plate_overrides = overrides_for_plate(data.filament_overrides, file_path, data.plate_id)
         plate_overrides = overrides_for_plate(data.filament_overrides, file_path, data.plate_id)
         if plate_overrides:
         if plate_overrides:
             filament_overrides_json = json.dumps(plate_overrides)
             filament_overrides_json = json.dumps(plate_overrides)
             # Update required_filament_types from overrides so scheduler validates against overridden types
             # Update required_filament_types from overrides so scheduler validates against overridden types
             override_types = sorted({o["type"] for o in plate_overrides if "type" in o})
             override_types = sorted({o["type"] for o in plate_overrides if "type" in o})
-            if override_types:
+            if override_types and target_model_norm:
                 # Merge with existing types (overrides may only cover some slots)
                 # Merge with existing types (overrides may only cover some slots)
                 existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
                 existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
                 # Replace types for overridden slots, keep others
                 # Replace types for overridden slots, keep others
@@ -971,6 +976,15 @@ async def add_to_queue(
                 batch_name_base = library_file.file_metadata.get("print_name") or library_file.filename
                 batch_name_base = library_file.file_metadata.get("print_name") or library_file.filename
             else:
             else:
                 batch_name_base = library_file.filename
                 batch_name_base = library_file.filename
+        elif variant_specs:
+            # A cross-model job carries neither archive_id nor library_file_id --
+            # the candidates are the files (#671) -- so both branches above miss
+            # and every such batch was named "Batch". Unreachable until the print
+            # dialog could ask for more than one copy of one (#3101). Name it
+            # after the first candidate, which is what the dialog names the job
+            # after and what the resolver prefers when both printers are free.
+            first_file = variant_specs[0][1]
+            batch_name_base = (first_file.file_metadata or {}).get("print_name") or first_file.filename or "Batch"
         batch_name_base = batch_name_base.replace(".gcode.3mf", "").replace(".3mf", "")
         batch_name_base = batch_name_base.replace(".gcode.3mf", "").replace(".3mf", "")
 
 
         batch = PrintBatch(
         batch = PrintBatch(

+ 122 - 68
backend/app/api/routes/printers.py

@@ -13,11 +13,12 @@ from starlette.background import BackgroundTask
 
 
 from backend.app.core import database
 from backend.app.core import database
 from backend.app.core.auth import (
 from backend.app.core.auth import (
-    RequireCameraStreamTokenIfAuthEnabled,
     RequireOverlayTokenIfAuthEnabled,
     RequireOverlayTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePrinterPermissionIfAuthEnabled,
     RequirePrinterPermissionIfAuthEnabled,
     is_auth_enabled,
     is_auth_enabled,
+    require_media_token_permission,
+    require_media_token_printer_permission,
 )
 )
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
@@ -87,9 +88,11 @@ from backend.app.services.printer_media import (
     remove_printer_files_zip,
     remove_printer_files_zip,
     start_printer_files_job,
     start_printer_files_job,
 )
 )
+from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
+from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.filament_ids import filament_id_to_setting_id
-from backend.app.utils.filament_types import printer_filament_type
+from backend.app.utils.filament_types import is_material_name, printer_filament_type
 from backend.app.utils.fts_routing import slot_extruder
 from backend.app.utils.fts_routing import slot_extruder
 from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
 from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
 from backend.app.utils.kprofile_lookup import build_slot_k_resolver
 from backend.app.utils.kprofile_lookup import build_slot_k_resolver
@@ -526,13 +529,14 @@ async def get_printer_status(
     ams_exists = False
     ams_exists = False
     raw_data = state.raw_data or {}
     raw_data = state.raw_data or {}
 
 
-    # K value for a slot's bound profile, resolved against its own nozzle.
+    # K value for a slot's bound profile, preferring the slot's own nozzle.
     #
     #
-    # Keyed on more than cali_idx: the printer numbers its calibration table
-    # per nozzle, so entry 16 exists on each and means a different profile on
-    # each. A cali_idx-only map let whichever profile the printer happened to
-    # list last overwrite the other, and the slot then displayed the wrong
-    # nozzle's K — on the maintainer's H2C, 0.018 and 0.020 for the same spool.
+    # cali_idx alone is not enough: two profiles can share an index and differ
+    # by extruder, and a cali_idx-only map let whichever the printer listed
+    # last overwrite the other — on the maintainer's H2C, 0.018 and 0.020 for
+    # the same spool. Nor is the extruder a requirement: one profile can be
+    # what both extruders' slots point at, and demanding a match blanked every
+    # slot on a second AMS (#3044). The resolver does both in order.
     _kprofile_k = build_slot_k_resolver(state)
     _kprofile_k = build_slot_k_resolver(state)
 
 
     # Cached active-cycle drying params (filament + target temp) we sent
     # Cached active-cycle drying params (filament + target temp) we sent
@@ -583,22 +587,11 @@ async def get_printer_status(
                         exists=tray_data.get("exists"),
                         exists=tray_data.get("exists"),
                     )
                     )
                 )
                 )
-            # Prefer humidity_raw (percentage) over humidity (index 1-5)
-            # humidity_raw is the actual percentage value from the sensor
-            humidity_raw = ams_data.get("humidity_raw")
-            humidity_idx = ams_data.get("humidity")
-            humidity_value = None
-
-            if humidity_raw is not None:
-                try:
-                    humidity_value = int(humidity_raw)
-                except (ValueError, TypeError):
-                    pass  # Skip unparseable humidity; will try index fallback
-            if humidity_value is None and humidity_idx is not None:
-                try:
-                    humidity_value = int(humidity_idx)
-                except (ValueError, TypeError):
-                    pass  # Skip unparseable humidity index; humidity remains None
+            # Percentage only. The 1-5 ``humidity`` index is never substituted
+            # for one -- it is inverted, so it would read as the opposite of
+            # what it means (#3140). See utils/ams_humidity.
+            humidity_pct = ams_humidity_percent(ams_data)
+            humidity_value = int(round(humidity_pct)) if humidity_pct is not None else None
             # AMS-HT has 1 tray, regular AMS has 4 trays
             # AMS-HT has 1 tray, regular AMS has 4 trays
             is_ams_ht = len(trays) == 1
             is_ams_ht = len(trays) == 1
 
 
@@ -1146,10 +1139,16 @@ async def _running_print_archive_file(printer_id: int, state) -> Path | None:
 async def get_printer_cover(
 async def get_printer_cover(
     printer_id: int,
     printer_id: int,
     view: str | None = None,
     view: str | None = None,
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    _: User | None = Depends(require_media_token_permission(Permission.PRINTERS_READ)),
 ):
 ):
     """Get the cover image for the current print job.
     """Get the cover image for the current print job.
 
 
+    Requires a media token query param (?token=xxx) when auth is enabled, plus
+    ``printers:read`` -- the permission that governs every other read of this
+    printer. It used to require ``camera:view`` by way of the camera-stream
+    token, which is a different question from "may this user see what is on the
+    plate" (#3025).
+
     Args:
     Args:
         view: Optional view type. Use "top" for the top-down build plate view or
         view: Optional view type. Use "top" for the top-down build plate view or
               "pick" for the slicer's object-ID mask used by skip objects.
               "pick" for the slicer's object-ID mask used by skip objects.
@@ -1970,7 +1969,7 @@ async def get_printer_file_plate_thumbnail(
     printer_id: int,
     printer_id: int,
     plate_index: int,
     plate_index: int,
     path: str = Query(..., description="Full path to the 3MF file on the printer"),
     path: str = Query(..., description="Full path to the 3MF file on the printer"),
-    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    _=Depends(require_media_token_printer_permission(Permission.PRINTERS_FILES)),
 ):
 ):
     """Get a plate thumbnail image from a printer-stored 3MF file."""
     """Get a plate thumbnail image from a printer-stored 3MF file."""
     import io
     import io
@@ -2849,10 +2848,49 @@ async def configure_ams_slot(
     if not client:
     if not client:
         raise HTTPException(status_code=400, detail="Printer not connected")
         raise HTTPException(status_code=400, detail="Printer not connected")
 
 
+    # Discard a tray_info_idx the printer cannot store (#3003).
+    #
+    # The field is 8 characters wide. A local preset id ("P" + 7 hex) is
+    # exactly 8, which is presumably why nobody noticed -- but a cloud
+    # *setting* id is 18, and the firmware keeps the first 8 and reports
+    # success. Measured on @marivo's A1 in the #3003 bundle:
+    #
+    #   sent      tray_info_idx=PFUS9ddc938fe3ab8f
+    #   printer   Assignment NOT confirmed: tray shows PFUS9DDC
+    #
+    # `PFUS9ddc` resolves to nothing anywhere, so the slot came out of the
+    # Configure modal as "Generic <material>" in the slicer -- strictly worse
+    # than the base filament it would have got from the fallback below, and it
+    # also breaks the calibration table, which is keyed by this field.
+    #
+    # Blanking it here is what hands the slot to the reuse / generic branch.
+    # The preset reference is not lost: it stays in setting_id, the field that
+    # does accept a PFUS. Same four rejected shapes, and the same reasoning, as
+    # `slicer_filament_resolver`'s closing guard -- which the assignment path
+    # has run since #1815 while Configure had none. The Orca profile UUID is on
+    # the list for the same reason as the rest: the modal no longer sends one,
+    # but this route is public API and 36 characters is the worst of the four
+    # against an 8-character field.
+    if tray_info_idx and (
+        tray_info_idx.startswith("PFUS")
+        or tray_info_idx.startswith("PFCN")
+        or _ORCA_PROFILE_ID.fullmatch(tray_info_idx)
+        or is_material_name(tray_info_idx)
+    ):
+        logger.info(
+            "[configure_ams_slot] tray_info_idx %r is not storable as a filament id — "
+            "falling back to slot reuse / generic (kept as setting_id %r)",
+            tray_info_idx,
+            setting_id or tray_info_idx,
+        )
+        if not setting_id and (tray_info_idx.startswith("PFUS") or tray_info_idx.startswith("PFCN")):
+            setting_id = tray_info_idx
+        tray_info_idx = ""
+
     # Resolve tray_info_idx for the MQTT command.
     # Resolve tray_info_idx for the MQTT command.
     # Priority:
     # Priority:
-    #   1. Use the provided tray_info_idx if set (including cloud-synced
-    #      custom presets like PFUS* / P*).
+    #   1. Use the provided tray_info_idx if set, once the guard above has had
+    #      its say (so: a GF* official or P* local id, never a PFUS/PFCN one).
     #   2. Reuse the slot's existing tray_info_idx if it's a specific
     #   2. Reuse the slot's existing tray_info_idx if it's a specific
     #      (non-generic) preset for the same material.
     #      (non-generic) preset for the same material.
     #   3. Fall back to a generic Bambu filament ID.
     #   3. Fall back to a generic Bambu filament ID.
@@ -3804,10 +3842,12 @@ async def bed_jog(
     distance: float = Query(
     distance: float = Query(
         ...,
         ...,
         description=(
         description=(
-            "Signed nozzle-bed gap adjustment in mm. Negative = decrease gap "
-            '("up" arrow in the UI: bed up on bed-on-Z models, toolhead down '
-            "on A1 bed-slingers). Positive = increase gap. The backend "
-            "translates this into the right G-code Z sign per printer model."
+            "Signed nozzle-bed gap adjustment in mm, identical on every model: "
+            "positive opens the gap (more clearance), negative closes it. Sent "
+            "to the printer as the G-code Z value unchanged — G-code Z is the "
+            "nozzle-to-bed distance whether the bed moves (X1 / P1 / H2) or the "
+            "toolhead does (A1 / A2L), so no per-model sign translation exists "
+            "or is needed."
         ),
         ),
     ),
     ),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
@@ -3817,31 +3857,49 @@ async def bed_jog(
 
 
     Emits a short G-code sequence via MQTT.
     Emits a short G-code sequence via MQTT.
 
 
-    Soft-endstop policy (#2579). The printer's software travel limits are the
-    only thing between a jog button and a bed crash — on Bambu machines the
-    physical endstops are homing-only (there is no runtime limit switch in the
-    travel path), so once they are disabled nothing stops the move. The old
-    code disabled them (``M211 S0``) around every forced jog, and the UI sent
-    ``force`` on every jog, so the limits were off on every bed move — that is
-    what let a jog drive the nozzle into the bed on all models (#2579). This
-    endpoint now emits a **bare relative move and never touches ``M211`` at
-    all** — byte-for-byte what the printer's own touchscreen jog sends, which
-    stops at the travel limit. Bambuddy no longer disables the firmware's soft
-    endstops, and it no longer sends ``M211 S1`` either: that was an unverified
-    attempt to re-enable a printer left disabled by an older build, and on real
-    hardware the jog moved past the limit *with* it. If a printer still jogs
-    past its limits, its endstops were disabled at the firmware level by the old
-    build — power-cycle it once to restore them; from then on Bambuddy leaves
-    them alone.
-
-    Direction handling: on bed-on-Z printers (X1 / P1 / H2 family) the bed
-    is the Z-axis, and Bambu's home convention puts Z=0 at the top with
-    Z+ moving the bed down — so a frontend "Up" (decrease gap) maps
-    naturally to ``G1 Z-``. On bed-slingers (A1 / A1 Mini) the Z-axis is
-    the *toolhead*, and ``G1 Z-`` instead drives the nozzle DOWN into the
-    bed (#1334 reported exactly that crash). For those models we invert
-    the sign before emitting the G-code, so the UI semantics stay the
-    same regardless of which part physically moves.
+    Soft-endstop policy (#2579). **Nothing clamps this move.** Bambu's firmware
+    does not enforce its soft endstops on G-code arriving over MQTT — measured
+    by logging the exact bytes to an H2D sitting at its Z limit: a clean
+    ``G91 / G1 Z-1.00 F600 / G90`` with no ``M211`` ran straight past, while the
+    printer's own touchscreen refuses the identical move, because the
+    touchscreen goes through the motion planner and ``gcode_line`` does not.
+    Push-status carries no axis position either, so there is nothing to clamp
+    against on this side. Treat every jog as unguarded; the jog popover says so
+    to the user, and a dead-reckoning clamp (track Z from a home, refuse
+    out-of-range moves) is the only real fix and is not built.
+
+    What Bambuddy stopped doing is making it worse. The old code wrapped every
+    move in ``M211 S0`` / ``M211 S1`` and the UI sent ``force`` on every jog, so
+    the limits came off on every bed move — and ``M211 S0`` disables them
+    *globally*, which broke the touchscreen's protection too until the printer
+    was power-cycled. That is the one genuine Bambuddy bug in #2579. This
+    endpoint now emits a bare relative move and never touches ``M211`` at all,
+    which leaves the touchscreen protected. It does not send ``M211 S1``
+    either: that was an unverified attempt to re-enable a printer an older
+    build had disabled, and on real hardware the jog moved past the limit
+    *with* it. A printer left in that state is recovered with one power cycle.
+
+    Direction (#1334, and the API half of it reported by @AQU4R1U5). ``Z``
+    is the nozzle-to-bed gap on every Bambu model, by definition of the
+    coordinate system rather than by convention: ``G1 Z+`` opens the gap
+    whether the bed drops away (X1 / P1 / H2, where Bambu's end G-code
+    parks with ``G1 Z{max_layer_z + 100}``) or the toolhead rises
+    (A1 / A1 Mini / A2L). The finish-photo plate restore relies on exactly
+    that and needs no model branch — see ``_restore_plate_for_finish_photo``.
+
+    So ``distance`` goes onto the wire unchanged, and one API call means one
+    physical thing on every printer: positive is always the safe direction.
+    This endpoint used to invert the sign on A1 models, which made a
+    documented model-independent parameter mean the opposite thing there —
+    ``distance=5``, asking for clearance, drove the toolhead at the plate.
+
+    What #1334 actually reported is a *label* problem, and it belongs to the
+    UI: the arrow says "move the plate up", and on a bed-slinger the plate
+    does not move in Z at all, so closing the gap shows up as the toolhead
+    diving. Which way an arrow points is a question about the machine in
+    front of the user, not about the G-code, so the printer card decides it
+    (``isBedSlinger`` in ``frontend/src/utils/bedSlinger.ts``) and sends the
+    gap it wants. Nothing here needs to know the model.
     """
     """
     if distance == 0 or abs(distance) > 200:
     if distance == 0 or abs(distance) > 200:
         raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
         raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
@@ -3855,14 +3913,10 @@ async def bed_jog(
     if not client:
     if not client:
         raise HTTPException(400, "Printer not connected")
         raise HTTPException(400, "Printer not connected")
 
 
-    from backend.app.services.printer_manager import is_bed_slinger
-
-    gcode_distance = -distance if is_bed_slinger(printer.model) else distance
-
-    # Bare relative move — exactly what the touchscreen sends. Never touch M211
-    # (#2579): the firmware keeps its soft endstops on by default and clamps the
-    # move at the travel limit.
-    lines = ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
+    # Bare relative move, never M211 (#2579). Not because a bare move is safe —
+    # the firmware ignores soft endstops on MQTT G-code either way — but because
+    # M211 S0 disabled them globally, taking the touchscreen's limits with it.
+    lines = ["G91", f"G1 Z{distance:.2f} F600", "G90"]
 
 
     if not client.send_gcode("\n".join(lines)):
     if not client.send_gcode("\n".join(lines)):
         raise HTTPException(500, "Failed to send bed-jog command")
         raise HTTPException(500, "Failed to send bed-jog command")
@@ -3897,9 +3951,9 @@ async def xy_jog(
     if y:
     if y:
         axes.append(f"Y{y:.2f}")
         axes.append(f"Y{y:.2f}")
 
 
-    # Bare relative move — never touch M211 (#2579). The firmware keeps its soft
-    # endstops on by default and clamps the move at the travel limit; a printer
-    # left disabled by an older build is recovered with a power cycle.
+    # Bare relative move, never M211 (#2579) — see the bed-jog docstring. The
+    # firmware does not enforce soft endstops on MQTT G-code, so this move is
+    # unguarded; M211 S0 only widened that to the touchscreen as well.
     if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
     if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
         raise HTTPException(500, "Failed to send XY jog command")
         raise HTTPException(500, "Failed to send XY jog command")
 
 

+ 10 - 4
backend/app/api/routes/projects.py

@@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.api.routes.library import get_library_dir
 from backend.app.api.routes.library import get_library_dir
-from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_media_token_permission
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
@@ -1412,13 +1412,19 @@ async def upload_project_cover_image(
 async def get_project_cover_image(
 async def get_project_cover_image(
     project_id: int,
     project_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    _: User | None = Depends(require_media_token_permission(Permission.PROJECTS_READ)),
 ):
 ):
     """Stream the project's cover image (#1155).
     """Stream the project's cover image (#1155).
 
 
     Browsers can't attach `Authorization: Bearer ...` to `<img src>` requests,
     Browsers can't attach `Authorization: Bearer ...` to `<img src>` requests,
-    so this route accepts the same `?token=` stream-credential as
-    /archives/{id}/thumbnail. The frontend wraps URLs with `withStreamToken`."""
+    so this route accepts a `?token=` media credential, the same one
+    /archives/{id}/thumbnail takes. The frontend wraps URLs with `withMediaToken`.
+
+    Gated on ``projects:read`` like every other project route. It used to take
+    the camera-stream token, which required ``camera:view`` instead -- an
+    unrelated permission that a user could hold without any project access, and
+    that a project reader could easily lack (#3025). Projects carry no
+    ``created_by_id``, so there is no per-row owner to check beyond that."""
     result = await db.execute(select(Project).where(Project.id == project_id))
     result = await db.execute(select(Project).where(Project.id == project_id))
     project = result.scalar_one_or_none()
     project = result.scalar_one_or_none()
     if not project:
     if not project:

+ 240 - 6
backend/app/api/routes/settings.py

@@ -11,8 +11,13 @@ from pydantic import BaseModel, Field
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
-from backend.app.core.config import settings as app_settings
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    caller_is_api_key,
+    require_auth_if_enabled,
+    require_energy_cost_update,
+)
+from backend.app.core.config import APP_VERSION, settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
@@ -490,6 +495,57 @@ async def get_ui_preferences(db: AsyncSession = Depends(get_db)):
     return {key: dumped[key] for key in _UI_PREFERENCE_FIELDS if key in dumped}
     return {key: dumped[key] for key in _UI_PREFERENCE_FIELDS if key in dumped}
 
 
 
 
+# Install configuration the app shell reads before it can render correctly.
+#
+# Deliberately a second list rather than more entries in _UI_PREFERENCE_FIELDS.
+# That one is served to anyone at all, on the recorded grounds that its contents
+# are "public defaults that ship with the app" (test_route_auth_coverage.py), and
+# its field set is pinned by a test written to make anyone adding to it stop and
+# think. These fields are not defaults -- they are facts about how this
+# particular deployment is configured -- so they get their own endpoint at their
+# own trust level instead of stretching that charter to fit them.
+_UI_FLAG_FIELDS: tuple[str, ...] = (
+    # The sidebar hides Finance unless billing is on. Layout read this from
+    # GET /settings, which requires SETTINGS_READ, so for a non-admin the query
+    # 403'd, the value arrived undefined, `undefined !== true` held, and the
+    # entry was hidden from exactly the users cost_centers:read_own exists to
+    # serve. The page itself was reachable by URL the whole time (#3023).
+    "billing_enabled",
+    # Same 403, opposite outcome. That gate tests `=== false`, which undefined
+    # never satisfies, so an administrator who turned user notifications off
+    # still left the entry showing -- to precisely the non-admins it governs.
+    "user_notifications_enabled",
+    # Not gates, but read by the shell and equally undefined for a non-admin:
+    # the sponsor prompt fell back to EUR whatever the install uses, and the
+    # update check ran even where it had been switched off.
+    "currency",
+    "check_updates",
+)
+
+
+@router.get("/ui-flags")
+async def get_ui_flags(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_auth_if_enabled),
+):
+    """Install configuration the app shell needs, for any signed-in user.
+
+    Gated on being authenticated rather than on ``SETTINGS_READ``. The sidebar
+    has to know whether billing is enabled before it can decide whether to offer
+    Finance, and ``SETTINGS_READ`` cannot be the price of knowing that -- it also
+    grants sight of the SMTP, LDAP and MQTT credentials.
+
+    ``require_auth_if_enabled`` returns ``None`` when auth is switched off
+    entirely, which is the case /ui-preferences was left ungated for. That is the
+    distinction the two endpoints draw: "works when there is no auth" is not the
+    same statement as "readable by anyone", and conflating them is what put a
+    settings read in front of a permission that was never meant to require one.
+    """
+    full = await _build_settings_response(db, is_api_key=False)
+    dumped = full.model_dump()
+    return {key: dumped[key] for key in _UI_FLAG_FIELDS if key in dumped}
+
+
 @router.get("/check-ffmpeg")
 @router.get("/check-ffmpeg")
 async def check_ffmpeg(
 async def check_ffmpeg(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
@@ -736,6 +792,20 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
                 except PermissionError as e:
                 except PermissionError as e:
                     logger.warning("Permission denied copying %s: %s", name, e)
                     logger.warning("Permission denied copying %s: %s", name, e)
 
 
+        # Say which version made this, so a restore that cannot import it can
+        # name the versions rather than a list of columns. Backups from before
+        # this existed simply have no manifest, and restore treats the version
+        # as unknown.
+        import json as _json
+
+        manifest = {
+            "format": 1,
+            "app_version": APP_VERSION,
+            "created_at": datetime.now().isoformat(timespec="seconds"),
+            "database": "sqlite" if is_sqlite() else "postgresql",
+        }
+        (temp_path / "manifest.json").write_text(_json.dumps(manifest, indent=2) + "\n")
+
         # Include the MFA encryption key as a ZIP top-level entry alongside
         # Include the MFA encryption key as a ZIP top-level entry alongside
         # bambuddy.db. Without it, encrypted client_secret / TOTP secret rows
         # bambuddy.db. Without it, encrypted client_secret / TOTP secret rows
         # would be unrecoverable after restore on a host without MFA_ENCRYPTION_KEY set.
         # would be unrecoverable after restore on a host without MFA_ENCRYPTION_KEY set.
@@ -797,6 +867,119 @@ async def create_backup(
         )
         )
 
 
 
 
+class BackupSchemaIncompatible(Exception):
+    """The backup has no value for a column this version requires.
+
+    A backup carries the schema of the install that made it. Restoring it into
+    a different version means the destination can have NOT NULL columns the
+    backup never heard of -- either because that version is older and still has
+    a column since removed (``user_wallets.currency``, dropped in #3123), or
+    because it is newer and has added one. Most such columns have a default and
+    can simply be filled. The ones that cannot are what this reports, and it has
+    to be reported BEFORE the restore drops anything: the Postgres import wipes
+    every table in the first transaction, so a failure halfway leaves the
+    install with an empty schema and the previous data gone.
+    """
+
+
+def _missing_required_columns(pg_table, src_columns: set[str]):
+    """Split the destination's NOT NULL columns that the backup lacks.
+
+    Returns ``(injectable, db_filled, unfillable)``:
+
+    * ``injectable`` -- ``{name: value}`` from the model's Python-side default.
+      These are invisible to the import's raw SQL: SQLAlchemy applies a
+      ``default=`` on ORM and Core inserts, never on ``text()``, and
+      ``create_all`` emits no DDL default for one. So a column like
+      ``currency VARCHAR(3) NOT NULL`` with ``default="EUR"`` arrives with
+      nothing to put in it unless we put it there.
+    * ``db_filled`` -- has a server default or is the autoincrement key; the
+      database fills it when the column is left out of the INSERT.
+    * ``unfillable`` -- nothing can supply a value. The backup is incompatible.
+    """
+    injectable: dict = {}
+    db_filled: list[str] = []
+    unfillable: list[str] = []
+
+    for col in pg_table.columns:
+        if col.nullable or col.name in src_columns:
+            continue
+        if col.default is not None:
+            arg = col.default.arg
+            injectable[col.name] = arg(None) if callable(arg) else arg
+        elif col.server_default is not None or col.primary_key:
+            db_filled.append(col.name)
+        else:
+            unfillable.append(col.name)
+
+    return injectable, db_filled, unfillable
+
+
+def check_backup_schema_compatible(sqlite_path: Path, backup_version: str | None = None) -> None:
+    """Raise if this version cannot import that backup. Touches nothing.
+
+    Only the cross-engine path needs this. A SQLite install restores by copying
+    the backup's pages, schema included, and `init_db()` migrates it forward
+    afterwards; the Postgres import instead recreates the schema from THIS
+    process's ORM and then inserts the backup's columns into it.
+    """
+    import sqlite3
+
+    from backend.app.core.database import Base
+
+    src = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
+    try:
+        src_tables = {
+            row[0]
+            for row in src.execute(
+                "SELECT name FROM sqlite_master WHERE type='table' "
+                "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'archive_fts%'"
+            )
+        }
+        problems: list[str] = []
+        # metadata.tables, not sorted_tables: the latter warns about the
+        # library_files/library_folders/print_archives cycle, and nothing here
+        # depends on the order.
+        for name, pg_table in Base.metadata.tables.items():
+            if name not in src_tables:
+                continue
+            # An empty table inserts nothing, so a column it cannot supply
+            # cannot fail. Refusing a restore over one would be a false alarm.
+            if src.execute(f'SELECT 1 FROM "{name}" LIMIT 1').fetchone() is None:  # noqa: S608  # nosec B608 — name comes from ORM metadata
+                continue
+            src_columns = {row[1] for row in src.execute(f'PRAGMA table_info("{name}")')}
+            _, _, unfillable = _missing_required_columns(pg_table, src_columns)
+            problems.extend(f"{name}.{col}" for col in unfillable)
+    finally:
+        src.close()
+
+    if not problems:
+        return
+
+    made_by = f"The backup was made by Bambuddy {backup_version}, " if backup_version else "The backup "
+    raise BackupSchemaIncompatible(
+        "This backup cannot be restored by this version of Bambuddy. It carries no value for "
+        f"{len(problems)} column(s) this version requires and cannot default: {', '.join(sorted(problems))}. "
+        f"{made_by}and this install runs {APP_VERSION}. Restore it on the version that made it, or "
+        "upgrade this install to that version. Nothing has been changed."
+    )
+
+
+def _read_backup_manifest(temp_path: Path) -> dict:
+    """The backup's manifest.json, or {} for a backup made before it existed."""
+    import json
+
+    path = temp_path / "manifest.json"
+    if not path.is_file():
+        return {}
+    try:
+        data = json.loads(path.read_text())
+    except (OSError, ValueError) as exc:
+        logger.warning("Ignoring unreadable backup manifest: %s", exc)
+        return {}
+    return data if isinstance(data, dict) else {}
+
+
 async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
 async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
     """Import data from a SQLite database file into the current PostgreSQL database.
     """Import data from a SQLite database file into the current PostgreSQL database.
 
 
@@ -809,6 +992,11 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
 
 
     from backend.app.core.database import Base, _create_engine
     from backend.app.core.database import Base, _create_engine
 
 
+    # Before anything is dropped. The route checks this too, earlier and with
+    # the backup's version in the message; this call is what makes the guarantee
+    # a property of the import itself rather than of one caller.
+    check_backup_schema_compatible(sqlite_path)
+
     # Create a temporary engine for the import (current engine was disposed)
     # Create a temporary engine for the import (current engine was disposed)
     pg_engine = _create_engine()
     pg_engine = _create_engine()
 
 
@@ -917,8 +1105,24 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
                 if not columns:
                 if not columns:
                     continue
                     continue
 
 
-                col_list = ", ".join(columns)
-                param_list = ", ".join(f":{c}" for c in columns)
+                # Columns this schema requires that the backup does not have at
+                # all. The block below handles a column PRESENT in the backup
+                # with a NULL in it; one the backup never had is not in
+                # `columns` and so never reached it -- which is how a backup
+                # from an install without `user_wallets.currency` died on
+                # NotNullViolationError against a version that still had it.
+                injected, _db_filled, _unfillable = _missing_required_columns(pg_table, set(src_columns))
+                if injected:
+                    logger.info(
+                        "Filling %s column(s) absent from the backup in %s: %s",
+                        len(injected),
+                        table_name,
+                        ", ".join(sorted(injected)),
+                    )
+
+                insert_columns = columns + list(injected)
+                col_list = ", ".join(insert_columns)
+                param_list = ", ".join(f":{c}" for c in insert_columns)
                 # ON CONFLICT DO NOTHING handles duplicate rows from SQLite (which doesn't enforce unique constraints)
                 # ON CONFLICT DO NOTHING handles duplicate rows from SQLite (which doesn't enforce unique constraints)
                 insert_sql = text(f"INSERT INTO {table_name} ({col_list}) VALUES ({param_list}) ON CONFLICT DO NOTHING")  # noqa: S608  # nosec B608
                 insert_sql = text(f"INSERT INTO {table_name} ({col_list}) VALUES ({param_list}) ON CONFLICT DO NOTHING")  # noqa: S608  # nosec B608
 
 
@@ -959,9 +1163,15 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
                 now = dt.now()
                 now = dt.now()
 
 
                 def _convert_row(
                 def _convert_row(
-                    row, cols=columns, bools=bool_columns, dts=datetime_columns, nn_defaults=not_null_defaults, _now=now
+                    row,
+                    cols=columns,
+                    bools=bool_columns,
+                    dts=datetime_columns,
+                    nn_defaults=not_null_defaults,
+                    _now=now,
+                    inject=injected,
                 ):
                 ):
-                    result = {}
+                    result = dict(inject)
                     for c in cols:
                     for c in cols:
                         val = row[c]
                         val = row[c]
                         if val is None and c in nn_defaults:
                         if val is None and c in nn_defaults:
@@ -1093,6 +1303,30 @@ async def restore_backup(
         if not backup_db.exists():
         if not backup_db.exists():
             raise HTTPException(400, "Invalid backup: missing bambuddy.db")
             raise HTTPException(400, "Invalid backup: missing bambuddy.db")
 
 
+        # 2b. Can this version import this backup at all?
+        #
+        # Deliberately here: everything below has a side effect. The virtual
+        # printer stops, background services stop, the MFA key file is
+        # overwritten with the backup's -- and then the Postgres import drops
+        # every table in its first transaction. A backup rejected at the INSERT
+        # took the install's data with it and left the encrypted secrets under a
+        # key that no longer matches. Nothing above this line has touched
+        # anything.
+        import sqlite3
+
+        manifest = _read_backup_manifest(temp_path)
+        backup_version = manifest.get("app_version")
+        if backup_version:
+            logger.info("Backup was created by Bambuddy %s; this install runs %s", backup_version, APP_VERSION)
+        if not is_sqlite():
+            try:
+                check_backup_schema_compatible(backup_db, backup_version)
+            except BackupSchemaIncompatible as exc:
+                logger.error("Refusing backup: %s", exc)
+                raise HTTPException(400, str(exc)) from exc
+            except sqlite3.DatabaseError as exc:
+                raise HTTPException(400, f"Invalid backup: bambuddy.db is not readable ({exc})") from exc
+
         try:
         try:
             import asyncio
             import asyncio
 
 

+ 2 - 1
backend/app/api/routes/slicer_presets.py

@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
+from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.orca_cloud import (
 from backend.app.api.routes.orca_cloud import (
     _ORCA_TYPE_TO_BAMBU,
     _ORCA_TYPE_TO_BAMBU,
     _build_authenticated_service as _build_orca_service,
     _build_authenticated_service as _build_orca_service,
@@ -43,6 +43,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudError,
     BambuCloudError,
     BambuCloudService,
     BambuCloudService,
 )
 )
+from backend.app.services.bambu_cloud_credentials import get_stored_token
 from backend.app.services.orca_cloud import (
 from backend.app.services.orca_cloud import (
     OrcaCloudAuthError,
     OrcaCloudAuthError,
     OrcaCloudError,
     OrcaCloudError,

+ 8 - 0
backend/app/api/routes/spoolbuddy.py

@@ -421,6 +421,11 @@ async def nfc_tag_scanned(
                             "material": mapped["material"],
                             "material": mapped["material"],
                             "subtype": mapped["subtype"],
                             "subtype": mapped["subtype"],
                             "color_name": mapped["color_name"],
                             "color_name": mapped["color_name"],
+                            # Spoolman stores no colour name, so `color_name`
+                            # here is usually the spool's subtype standing in
+                            # for one. The kiosk needs to know that to prefer
+                            # the colour catalog over "Silk+" (#3090).
+                            "color_name_is_synthesized": mapped["color_name_is_synthesized"],
                             "rgba": mapped["rgba"],
                             "rgba": mapped["rgba"],
                             "brand": mapped["brand"],
                             "brand": mapped["brand"],
                             "label_weight": mapped["label_weight"],
                             "label_weight": mapped["label_weight"],
@@ -485,6 +490,9 @@ async def nfc_tag_scanned(
                         "material": spool.material,
                         "material": spool.material,
                         "subtype": spool.subtype,
                         "subtype": spool.subtype,
                         "color_name": spool.color_name,
                         "color_name": spool.color_name,
+                        # Local inventory stores what the user or their tag
+                        # set, and nothing else — never a stand-in (#3090).
+                        "color_name_is_synthesized": False,
                         "rgba": spool.rgba,
                         "rgba": spool.rgba,
                         "brand": spool.brand,
                         "brand": spool.brand,
                         "label_weight": spool.label_weight,
                         "label_weight": spool.label_weight,

+ 33 - 8
backend/app/api/routes/spoolman_inventory.py

@@ -62,6 +62,7 @@ from backend.app.services.spoolman import (
     init_spoolman_client,
     init_spoolman_client,
 )
 )
 from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
 from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
+from backend.app.services.tag_conflict import tag_already_linked
 from backend.app.utils.color_utils import spoolman_color_hex
 from backend.app.utils.color_utils import spoolman_color_hex
 from backend.app.utils.filament_ids import (
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_FILAMENT_IDS,
@@ -1154,6 +1155,18 @@ async def sync_spool_weight(
     return {"status": "ok", "weight_used": weight_used}
     return {"status": "ok", "weight_used": weight_used}
 
 
 
 
+def _extra_tag(spool: dict) -> str:
+    """The tag stored in a Spoolman spool's ``extra``, normalised for comparison.
+
+    Anything that is not a string reads as no tag. ``extra`` is free-form and
+    edited outside Bambuddy, and ``.get("tag", "")`` does not default a key
+    that is present and null -- that returns None, which has no ``.strip``.
+    """
+    extra = spool.get("extra")
+    raw = extra.get("tag") if isinstance(extra, dict) else None
+    return raw.strip('"').upper() if isinstance(raw, str) else ""
+
+
 @router.patch("/spools/{spool_id}/tag")
 @router.patch("/spools/{spool_id}/tag")
 async def link_tag_to_spoolman_spool(
 async def link_tag_to_spoolman_spool(
     *,
     *,
@@ -1165,8 +1178,10 @@ async def link_tag_to_spoolman_spool(
     """Write an NFC tag UID or Bambu tray UUID into Spoolman's extra.tag for a spool.
     """Write an NFC tag UID or Bambu tray UUID into Spoolman's extra.tag for a spool.
 
 
     tray_uuid takes precedence over tag_uid when both are supplied.
     tray_uuid takes precedence over tag_uid when both are supplied.
-    Returns 409 if another spool already carries the same tag.
     Uses extra_lock to serialise against concurrent extra-field writes.
     Uses extra_lock to serialise against concurrent extra-field writes.
+
+    A tag another active spool already carries is refused with the shared
+    ``tag_already_linked`` 409, identical to the built-in route's (#3110).
     """
     """
     client = await _get_client(db)
     client = await _get_client(db)
     tag = (data.tray_uuid or data.tag_uid).upper()
     tag = (data.tray_uuid or data.tag_uid).upper()
@@ -1174,15 +1189,25 @@ async def link_tag_to_spoolman_spool(
 
 
     async with client.extra_lock(spool_id):
     async with client.extra_lock(spool_id):
         # Duplicate check: scan all spools for the same tag on a different spool.
         # Duplicate check: scan all spools for the same tag on a different spool.
+        # Sorted, because Spoolman has no unique constraint on extra.tag either,
+        # and a caller offered whichever row the scan happened to reach first
+        # could not tell two holders apart. The built-in route names the lowest
+        # id for the same reason (#3110).
+        #
+        # Sorting means every row is read, where the old loop stopped at its
+        # first match, so one malformed row after the holder must not be able
+        # to take the whole request down: _extra_tag refuses a non-string, and
+        # a row without an integer id cannot be named and so is not treated as
+        # a holder. Bambuddy only ever writes a JSON string here; a third party
+        # editing extra.tag in Spoolman is what puts anything else in reach.
         async with _translate_spoolman_errors():
         async with _translate_spoolman_errors():
             all_spools = await client.get_all_spools()
             all_spools = await client.get_all_spools()
-        for s in all_spools:
-            s_tag = (s.get("extra") or {}).get("tag", "")
-            if s_tag.strip('"').upper() == tag and s.get("id") != spool_id:
-                raise HTTPException(
-                    status_code=409,
-                    detail=f"Tag is already assigned to spool {s['id']}",
-                )
+        holders = sorted(
+            (s for s in all_spools if _extra_tag(s) == tag and isinstance(s.get("id"), int) and s["id"] != spool_id),
+            key=lambda s: s["id"],
+        )
+        if holders:
+            raise tag_already_linked("tray_uuid" if data.tray_uuid else "tag_uid", holders[0]["id"])
 
 
         # Re-fetch inside the lock so cur_extra reflects any concurrent update.
         # Re-fetch inside the lock so cur_extra reflects any concurrent update.
         async with _translate_spoolman_errors():
         async with _translate_spoolman_errors():

+ 6 - 1
backend/app/api/routes/support.py

@@ -33,7 +33,7 @@ from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.services.discovery import is_running_in_docker
+from backend.app.services.discovery import detect_container_runtime, is_running_in_docker
 from backend.app.services.log_reader import (
 from backend.app.services.log_reader import (
     LogEntry,
     LogEntry,
     collect_sensitive_strings,
     collect_sensitive_strings,
@@ -806,6 +806,11 @@ async def _collect_support_info() -> dict:
         },
         },
         "environment": {
         "environment": {
             "docker": in_docker,
             "docker": in_docker,
+            # Named separately from the Docker flag: a Podman or LXC bundle
+            # used to carry `"docker": false` and nothing else, which reads
+            # as bare metal and hid the deployment shape a report depended on
+            # (#3092).
+            "container_runtime": detect_container_runtime(),
             "data_dir": _sanitize_path(str(settings.base_dir)),
             "data_dir": _sanitize_path(str(settings.base_dir)),
             "log_dir": _sanitize_path(str(settings.log_dir)),
             "log_dir": _sanitize_path(str(settings.log_dir)),
             "timezone": os.environ.get("TZ", ""),
             "timezone": os.environ.get("TZ", ""),

+ 269 - 18
backend/app/core/auth.py

@@ -671,14 +671,20 @@ async def resolve_session_max_minutes(db: AsyncSession) -> int:
 
 
 
 
 # --- Slicer download tokens ---
 # --- Slicer download tokens ---
-# Short-lived, single-use tokens for slicer protocol handlers that can't send
-# auth headers.  Stored in AuthEphemeralToken (token_type=TokenType.SLICER_DOWNLOAD)
-# so they survive server restarts and work in multi-worker deployments (M-3).
+# Short-lived, resource-bound tokens for slicer protocol handlers and browser
+# downloads that can't send auth headers.  Stored in AuthEphemeralToken
+# (token_type=TokenType.SLICER_DOWNLOAD) so they survive server restarts and
+# work in multi-worker deployments (M-3).
+#
+# Whether redemption consumes the token is the *caller's* choice, made at
+# verify time -- see ``verify_slicer_download_token``.  The row is identical
+# either way, so a token is never "the reusable kind"; the endpoint it is
+# presented to decides.
 SLICER_TOKEN_EXPIRE_MINUTES = 5
 SLICER_TOKEN_EXPIRE_MINUTES = 5
 
 
 
 
 async def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
 async def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
-    """Create a short-lived, single-use download token for slicer protocol handlers."""
+    """Create a short-lived download token for slicer protocol handlers."""
     now = datetime.now(timezone.utc)
     now = datetime.now(timezone.utc)
     expires_at = now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES)
     expires_at = now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES)
     token = secrets.token_urlsafe(24)
     token = secrets.token_urlsafe(24)
@@ -703,30 +709,49 @@ async def create_slicer_download_token(resource_type: str, resource_id: int) ->
     return token
     return token
 
 
 
 
-async def verify_slicer_download_token(token: str, resource_type: str, resource_id: int) -> bool:
-    """Verify and atomically consume a slicer download token.
+async def verify_slicer_download_token(
+    token: str,
+    resource_type: str,
+    resource_id: int,
+    *,
+    single_use: bool = True,
+) -> bool:
+    """Verify a slicer download token, consuming it unless ``single_use`` is False.
 
 
     Returns True only if the token is valid, unexpired, and bound to the given resource.
     Returns True only if the token is valid, unexpired, and bound to the given resource.
-    DELETE...RETURNING ensures the token is single-use even under concurrent requests.
 
 
-    M-NEW-1 fix: nonce (resource key) is included in the WHERE clause so the DELETE
+    With ``single_use=True`` (the default) redemption is a DELETE...RETURNING, which
+    keeps the token one-shot even under concurrent requests.  Use it wherever the
+    thing being downloaded is itself consumed -- the prepared printer bundle is
+    deleted once streamed, so a second redemption could only ever 404.
+
+    With ``single_use=False`` the token stays valid for the rest of its five-minute
+    TTL.  Use it for the URLs handed to an external slicer over a protocol handler:
+    we do not control that process, and one-shot redemption breaks the moment
+    anything fetches the URL twice -- a retry after a transient failure (Bambu
+    Studio retries three times), a resumed transfer, a redirect follow, an
+    on-access scanner.  The first fetch would win and the slicer would be left
+    with a 403 (#3029).  Resource binding and expiry are unchanged; only the
+    number of redemptions inside the TTL differs.
+
+    M-NEW-1 fix: nonce (resource key) is included in the WHERE clause so redemption
     only succeeds when the token is presented to the *correct* resource endpoint.
     only succeeds when the token is presented to the *correct* resource endpoint.
     Previously the token was consumed (committed) even when stored_key != expected_key,
     Previously the token was consumed (committed) even when stored_key != expected_key,
     permanently invalidating it while returning False to the caller.
     permanently invalidating it while returning False to the caller.
     """
     """
     expected_key = f"{resource_type}:{resource_id}"
     expected_key = f"{resource_type}:{resource_id}"
     now = datetime.now(timezone.utc)
     now = datetime.now(timezone.utc)
+    bound = (
+        AuthEphemeralToken.token == token,
+        AuthEphemeralToken.token_type == TokenType.SLICER_DOWNLOAD,
+        AuthEphemeralToken.nonce == expected_key,
+        AuthEphemeralToken.expires_at > now,
+    )
     async with async_session() as db:
     async with async_session() as db:
-        result = await db.execute(
-            delete(AuthEphemeralToken)
-            .where(
-                AuthEphemeralToken.token == token,
-                AuthEphemeralToken.token_type == TokenType.SLICER_DOWNLOAD,
-                AuthEphemeralToken.nonce == expected_key,
-                AuthEphemeralToken.expires_at > now,
-            )
-            .returning(AuthEphemeralToken.id)
-        )
+        if not single_use:
+            result = await db.execute(select(AuthEphemeralToken.id).where(*bound))
+            return result.scalar_one_or_none() is not None
+        result = await db.execute(delete(AuthEphemeralToken).where(*bound).returning(AuthEphemeralToken.id))
         if result.one_or_none() is None:
         if result.one_or_none() is None:
             return False
             return False
         await db.commit()
         await db.commit()
@@ -738,6 +763,11 @@ async def verify_slicer_download_token(token: str, resource_type: str, resource_
 # tags (these cannot send Authorization headers).  Unlike slicer tokens they are
 # tags (these cannot send Authorization headers).  Unlike slicer tokens they are
 # NOT single-use — streams reconnect on errors.  Stored in AuthEphemeralToken
 # NOT single-use — streams reconnect on errors.  Stored in AuthEphemeralToken
 # (token_type="camera_stream") for multi-worker compatibility (M-3).
 # (token_type="camera_stream") for multi-worker compatibility (M-3).
+#
+# Anonymous by design: the row records no username, so a route guarded by this
+# token knows only "some camera viewer", never which one.  That is fine for a
+# live stream, which is per-printer and not per-user, and is precisely why
+# non-camera media moved to the identified media token in #3025.
 CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
 CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
 
 
 
 
@@ -893,6 +923,89 @@ async def verify_overlay_token(token: str) -> bool:
         return record is not None
         return record is not None
 
 
 
 
+# --- Media tokens (#3025) ---
+# Browsers cannot attach ``Authorization`` headers to ``<img src>`` / ``<video
+# src>``, so image routes need a credential that fits in a query parameter.
+# Until #3025 they borrowed the *camera stream* token for that, which had two
+# costs: minting one requires ``camera:view``, so a user could not see a
+# library thumbnail without also being handed the live camera pointed at the
+# operator's room; and a camera-stream token records no principal at all, so
+# the thirteen non-camera routes had no identity to check ownership against
+# and returned any row to any holder.
+#
+# A media token fixes both by following the *websocket* token instead: it
+# stores the username, so ``require_media_token_*`` can resolve the real user
+# and apply the same per-row visibility gate the header-authenticated sibling
+# routes already use. Like the websocket token it is not consumed (a page of
+# thumbnails is many requests) and it outlives a password change by up to its
+# TTL -- acceptable for read-only media at 60 minutes, and identical to the
+# guarantee ``/api/v1/ws`` has made since GHSA-r2qv.
+MEDIA_TOKEN_EXPIRE_MINUTES = 60
+
+
+async def create_media_token(username: str | None) -> str:
+    """Create a reusable token for media (thumbnail / preview / icon) routes.
+
+    Records the issuing principal in ``username`` exactly as
+    :func:`create_websocket_token` does. API-keyed callers reach this with
+    ``None`` and get the empty string, which :func:`verify_media_token`
+    reports back and the dependencies then reject while auth is enabled --
+    an API key has no per-row ownership identity, and it does not need one
+    here because the media routes accept ``X-API-Key`` directly.
+    """
+    now = datetime.now(timezone.utc)
+    expires_at = now + timedelta(minutes=MEDIA_TOKEN_EXPIRE_MINUTES)
+    token = secrets.token_urlsafe(24)
+    async with async_session() as db:
+        # Prune expired tokens opportunistically (same shape as camera/websocket).
+        await db.execute(
+            delete(AuthEphemeralToken).where(
+                AuthEphemeralToken.token_type == "media",
+                AuthEphemeralToken.expires_at < now,
+            )
+        )
+        db.add(
+            AuthEphemeralToken(
+                token=token,
+                token_type="media",
+                username=username or "",
+                expires_at=expires_at,
+            )
+        )
+        await db.commit()
+    return token
+
+
+async def verify_media_token(token: str) -> str | None:
+    """Verify a media token, returning the username it was minted for.
+
+    Returns ``""`` for a token minted by an API key (no per-row identity) and
+    ``None`` when the token is missing / expired / unknown. Not consumed --
+    one token serves every image on a page.
+
+    Deliberately narrower than :func:`verify_camera_stream_token`: no
+    long-lived scope passes here. ``camera_stream`` / ``camwall`` / ``overlay``
+    tokens are handed to kiosks, walls and Home Assistant to display *video*,
+    and are anonymous by construction, so accepting one would reinstate the
+    unowned read this token type exists to close (#3025). The inverse also
+    holds -- see :func:`verify_camwall_token`, which refuses a camera-stream
+    token for the same reason in the other direction.
+    """
+    now = datetime.now(timezone.utc)
+    async with async_session() as db:
+        result = await db.execute(
+            select(AuthEphemeralToken).where(
+                AuthEphemeralToken.token == token,
+                AuthEphemeralToken.token_type == "media",
+                AuthEphemeralToken.expires_at > now,
+            )
+        )
+        row = result.scalar_one_or_none()
+        if row is None:
+            return None
+        return row.username or ""
+
+
 def verify_password(plain_password: str, hashed_password: str) -> bool:
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verify a password against a hash.
     """Verify a password against a hash.
 
 
@@ -2037,6 +2150,12 @@ def require_camera_stream_token_if_auth_enabled():
     Used for camera stream/snapshot endpoints that are loaded via <img> tags
     Used for camera stream/snapshot endpoints that are loaded via <img> tags
     which cannot send Authorization headers. The frontend obtains a token from
     which cannot send Authorization headers. The frontend obtains a token from
     POST /printers/camera/stream-token and appends it as ?token=xxx.
     POST /printers/camera/stream-token and appends it as ?token=xxx.
+
+    Camera routes only. Non-camera media (thumbnails, plate previews,
+    timelapses, cover images, icons) takes ``require_media_token_*``: minting a
+    camera-stream token costs ``camera:view``, which no thumbnail should
+    require, and the token names no principal, so a route guarded by it cannot
+    tell one user's rows from another's (#3025).
     """
     """
 
 
     async def checker(token: str | None = None) -> None:
     async def checker(token: str | None = None) -> None:
@@ -2230,3 +2349,135 @@ def require_ownership_permission(
             )
             )
 
 
     return checker
     return checker
+
+
+async def _user_from_media_token(token: str) -> User:
+    """Resolve the ``User`` a media token was minted for, or raise 401 (#3025).
+
+    Fail-closed on every miss: an unknown/expired token, a token minted by an
+    API key (empty username -- see :func:`create_media_token`), a username no
+    longer in the table, and a deactivated account all raise rather than fall
+    through to an anonymous read. The 401 detail names the mint endpoint so a
+    stale tab knows how to recover, and the frontend's error handler refreshes
+    the token on the first failed <img> load.
+    """
+    unauthorized = HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Valid media token required. Obtain one from POST /api/v1/auth/media-token",
+    )
+    username = await verify_media_token(token)
+    if not username:
+        raise unauthorized
+    async with async_session() as db:
+        user = await get_user_by_username(db, username)
+    if user is None or not user.is_active:
+        raise unauthorized
+    return user
+
+
+def require_media_token_permission(*permissions: str | Permission):
+    """Media-route dependency for resources with no per-row ownership (#3025).
+
+    Accepts either a ``?token=`` media token (the ``<img>`` case) or the
+    ordinary ``Authorization`` / ``X-API-Key`` headers, so a ``fetch()`` or an
+    API-keyed integration authenticates here exactly as it does on the
+    resource's sibling routes. Requires ALL of ``permissions``, matching
+    :func:`require_permission_if_auth_enabled`.
+
+    Returns the resolved ``User``, or ``None`` when auth is disabled or the
+    caller is an API key -- the same ``User | None`` contract the header-only
+    dependency has, so handlers need no new branch.
+    """
+    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
+    header_checker = require_permission_if_auth_enabled(*permissions)
+
+    async def checker(
+        token: str | None = None,
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> User | None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return None  # Auth disabled, allow access
+        if token:
+            user = await _user_from_media_token(token)
+            missing = [p for p in perm_strings if not user.has_permission(p)]
+            if missing:
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail=f"Missing required permissions: {', '.join(missing)}",
+                )
+            return user
+        return await header_checker(credentials=credentials, x_api_key=x_api_key)
+
+    return checker
+
+
+def require_media_token_ownership(
+    all_permission: str | Permission,
+    own_permission: str | Permission,
+):
+    """Media-route dependency for ownership-scoped resources (#3025).
+
+    The ownership counterpart of :func:`require_media_token_permission`, and
+    the reason media tokens carry a principal at all: it returns the same
+    ``(user, can_read_all)`` pair as :func:`require_ownership_permission`, so a
+    thumbnail route can hand it straight to the ``_ensure_*_visible`` gate its
+    header-authenticated siblings already use instead of serving any row to any
+    token holder.
+
+    Header callers are delegated to :func:`require_ownership_permission`
+    unchanged -- including its API-key rule, where a key satisfying the ALL
+    permission's scope flag gets ``can_read_all=True`` because keys have no
+    per-row identity.
+    """
+    all_perm = all_permission.value if isinstance(all_permission, Permission) else all_permission
+    own_perm = own_permission.value if isinstance(own_permission, Permission) else own_permission
+    header_checker = require_ownership_permission(all_permission, own_permission)
+
+    async def checker(
+        token: str | None = None,
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> tuple[User | None, bool]:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return None, True  # Auth disabled, allow all
+        if token:
+            user = await _user_from_media_token(token)
+            if user.has_permission(all_perm):
+                return user, True
+            if user.has_permission(own_perm):
+                return user, False
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail=f"Missing permission: {own_perm} or {all_perm}",
+            )
+        return await header_checker(credentials=credentials, x_api_key=x_api_key)
+
+    return checker
+
+
+def require_media_token_printer_permission(permission: str | Permission):
+    """Media-route dependency for per-printer resources (#3025).
+
+    :func:`require_media_token_permission` plus the API key's per-printer
+    allowlist, mirroring :func:`require_printer_permission_if_auth_enabled`.
+    Only the header path can present an API key -- a media token resolves to a
+    real user or to nothing -- so the allowlist check applies there alone.
+    """
+    media_checker = require_media_token_permission(permission)
+
+    async def checker(
+        printer_id: int,
+        token: str | None = None,
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> User | None:
+        user = await media_checker(token=token, credentials=credentials, x_api_key=x_api_key)
+        api_key = await validated_api_key_from_request(credentials, x_api_key)
+        if api_key is not None:
+            check_printer_access(api_key, printer_id)
+        return user
+
+    return checker

+ 1 - 1
backend/app/core/config.py

@@ -7,7 +7,7 @@ from pydantic import Field
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "1.2.5.5"
+APP_VERSION = "1.2.5.6"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
 

+ 33 - 2
backend/app/core/database.py

@@ -1297,7 +1297,6 @@ async def _migrate_create_finance_tables(conn) -> None:
                 id INTEGER PRIMARY KEY,
                 id INTEGER PRIMARY KEY,
                 user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
                 user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
                 balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
                 balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
-                currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
                 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
                 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
             )
             )
             """,
             """,
@@ -1366,7 +1365,6 @@ async def _migrate_create_finance_tables(conn) -> None:
                 id SERIAL PRIMARY KEY,
                 id SERIAL PRIMARY KEY,
                 user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
                 user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
                 balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
                 balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
-                currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
                 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
                 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
             )
             )
             """,
             """,
@@ -1471,6 +1469,35 @@ async def _migrate_finance_money_to_numeric(conn) -> None:
             )
             )
 
 
 
 
+async def _migrate_drop_wallet_currency(conn) -> None:
+    """Remove ``user_wallets.currency`` (#3123).
+
+    An install has one currency, held in the ``currency`` app setting. The
+    column stored whatever was configured when a wallet row happened to be
+    created -- and three of its four writers hardcoded "EUR" -- so it could
+    only ever disagree with the setting. Everything reads the setting now, so
+    the column would otherwise sit here unread -- a trap for the next person
+    who finds it and assumes it means something.
+
+    Skipped on SQLite older than 3.35, which has no DROP COLUMN. Leaving the
+    column in place there costs nothing: no code references it and it carries
+    a DEFAULT, so inserts that omit it still succeed.
+    """
+    if is_sqlite():
+        import sqlite3
+
+        if sqlite3.sqlite_version_info < (3, 35, 0):
+            logger.info(
+                "SQLite %s has no ALTER TABLE DROP COLUMN; leaving the unused user_wallets.currency in place",
+                sqlite3.sqlite_version,
+            )
+            return
+        await _safe_execute(conn, "ALTER TABLE user_wallets DROP COLUMN currency")
+        return
+
+    await _safe_execute(conn, "ALTER TABLE user_wallets DROP COLUMN IF EXISTS currency")
+
+
 async def _migrate_add_print_archive_cost_center(conn) -> None:
 async def _migrate_add_print_archive_cost_center(conn) -> None:
     """Add the nullable cost-center link missing from pre-billing archives."""
     """Add the nullable cost-center link missing from pre-billing archives."""
     await _safe_execute(
     await _safe_execute(
@@ -1868,6 +1895,10 @@ async def run_migrations(conn):
     await _migrate_finance_money_to_numeric(conn)
     await _migrate_finance_money_to_numeric(conn)
     await _migrate_create_finance_indexes(conn)
     await _migrate_create_finance_indexes(conn)
 
 
+    # Runs after the CREATE TABLE above, which used to re-add the column on an
+    # install whose finance tables predate the ORM (#3123).
+    await _migrate_drop_wallet_currency(conn)
+
     # Migration: Add missing-spool-assignment print-start notification toggle
     # Migration: Add missing-spool-assignment print-start notification toggle
     try:
     try:
         async with conn.begin_nested():
         async with conn.begin_nested():

+ 206 - 47
backend/app/main.py

@@ -115,6 +115,7 @@ from backend.app.services.obico_detection import obico_detection_service
 from backend.app.services.print_cost_estimate import plate_scoped_run_estimate as _plate_scoped_run_estimate
 from backend.app.services.print_cost_estimate import plate_scoped_run_estimate as _plate_scoped_run_estimate
 from backend.app.services.print_scheduler import scheduler as print_scheduler
 from backend.app.services.print_scheduler import scheduler as print_scheduler
 from backend.app.services.print_storage import (
 from backend.app.services.print_storage import (
+    REASON_FTP_TRANSFER_FAILED,
     REASON_FTPS_COOLOFF,
     REASON_FTPS_COOLOFF,
     external_storage_present,
     external_storage_present,
     ftp_probe_paths,
     ftp_probe_paths,
@@ -146,6 +147,7 @@ from backend.app.services.spoolman_tracking import (
 )
 )
 from backend.app.services.tasmota import tasmota_service
 from backend.app.services.tasmota import tasmota_service
 from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
 from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
+from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.filament_types import printer_filament_type
 from backend.app.utils.filament_types import printer_filament_type
 from backend.app.utils.fts_routing import extruder_for_inlet
 from backend.app.utils.fts_routing import extruder_for_inlet
 from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.local_time import utcnow_naive
@@ -2021,6 +2023,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
             from backend.app.api.routes.inventory import _find_tray_in_ams_data
             from backend.app.api.routes.inventory import _find_tray_in_ams_data
             from backend.app.models.spool import Spool as _Spool
             from backend.app.models.spool import Spool as _Spool
             from backend.app.models.spool_assignment import SpoolAssignment as SA
             from backend.app.models.spool_assignment import SpoolAssignment as SA
+            from backend.app.services.ams_slot_presence import spool_present
             from backend.app.services.inventory_mode import spoolman_owns_assignments
             from backend.app.services.inventory_mode import spoolman_owns_assignments
 
 
             # Built-in assignments only. Since #2812 they survive a switch to
             # Built-in assignments only. Since #2812 they survive a switch to
@@ -2137,7 +2140,18 @@ async def on_ams_change(printer_id: int, ams_data: list):
                     # (#1322). The state ∉ {9,10} guard keeps the firmware's
                     # (#1322). The state ∉ {9,10} guard keeps the firmware's
                     # explicit "empty" signals authoritative over any stale
                     # explicit "empty" signals authoritative over any stale
                     # tray_type that might survive the relay's auto-clearing.
                     # tray_type that might survive the relay's auto-clearing.
-                    loaded = cur_state == 11 or (cur_state not in (9, 10) and cur_type.strip())
+                    #
+                    # tray_exist_bits comes first because that guard cannot tell
+                    # a firmware "empty" from Bambuddy's own: apply_tray_exist_bits
+                    # writes state=9 when the bit is 0 and leaves it there when the
+                    # bit returns. A non-RFID spool inserted into a pre-assigned
+                    # slot brings no tray_type with it, so the stale 9 made this
+                    # expression false forever and the deferred config never fired
+                    # — the deadlock #1322 removed from the assign path, still in
+                    # place here (#3084, #3100).
+                    loaded = spool_present(current_tray) is True or (
+                        cur_state == 11 or (cur_state not in (9, 10) and cur_type.strip())
+                    )
                     if not fp_type.strip() and loaded and assignment.spool:
                     if not fp_type.strip() and loaded and assignment.spool:
                         try:
                         try:
                             from backend.app.api.routes.inventory import (
                             from backend.app.api.routes.inventory import (
@@ -2187,6 +2201,23 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                 assignment.tray_id,
                                 assignment.tray_id,
                             )
                             )
                             continue
                             continue
+                        # Same reasoning off the print, on firmware's own say-so:
+                        # a blank tray report from a slot whose tray_exist_bits
+                        # bit is set describes a spool the AMS cannot identify —
+                        # a non-RFID one, or one whose slot was reset — not a
+                        # spool that was taken out. Deleting the assignment there
+                        # threw away the identity the user had supplied, which is
+                        # the only place it existed (#3100). A slot the bit calls
+                        # empty, or one that carries no bit at all, still unlinks.
+                        if spool_present(current_tray) is True and not cur_color.strip() and not cur_type.strip():
+                            logger.info(
+                                "Auto-unlink skipped: spool %d AMS%d-T%d — slot still occupied, "
+                                "tray reports no filament data yet",
+                                assignment.spool_id,
+                                assignment.ams_id,
+                                assignment.tray_id,
+                            )
+                            continue
                         # Fingerprint mismatch — but check if tray now matches the
                         # Fingerprint mismatch — but check if tray now matches the
                         # assigned spool (e.g. auto-configure changed the tray).
                         # assigned spool (e.g. auto-configure changed the tray).
                         # Both sides are reduced to the type the slot can carry
                         # Both sides are reduced to the type the slot can carry
@@ -2593,6 +2624,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
 
 
             from backend.app.models.spool_assignment import SpoolAssignment
             from backend.app.models.spool_assignment import SpoolAssignment
             from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
             from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+            from backend.app.services.ams_slot_presence import spool_present
             from backend.app.services.inventory_mode import spoolman_owns_assignments
             from backend.app.services.inventory_mode import spoolman_owns_assignments
 
 
             # Built-in remaining weight, used by sync_ams_tray only when the
             # Built-in remaining weight, used by sync_ams_tray only when the
@@ -2661,7 +2693,16 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         # completion (#1459), so deleting the row mid-print
                         # completion (#1459), so deleting the row mid-print
                         # loses the runout segment's usage — the same failure
                         # loses the runout segment's usage — the same failure
                         # the internal inventory's auto-unlink had.
                         # the internal inventory's auto-unlink had.
-                        if not printing_now:
+                        #
+                        # Nor when firmware's presence bit says the slot is
+                        # occupied. parse_ams_tray calls a tray with no type or
+                        # no colour empty, and a spool the AMS cannot read has
+                        # neither until something configures it — so a tag-less
+                        # spool assigned through the UI had its row deleted by
+                        # the first idle push after it was inserted. Same
+                        # deletion as the internal inventory's in #3100, same
+                        # answer, so the two modes stay in step.
+                        if not printing_now and spool_present(tray_data) is not True:
                             empty_slots.append((ams_id, tray_id_raw))
                             empty_slots.append((ams_id, tray_id_raw))
                         _clear_unknown_tag_dedup(printer_id, ams_id, tray_id_raw)
                         _clear_unknown_tag_dedup(printer_id, ams_id, tray_id_raw)
                         continue
                         continue
@@ -3092,6 +3133,14 @@ async def _restore_printable_objects(printer_id: int, state, db, logger) -> None
 # armed a fresh one. Module-level so tests can shrink them.
 # armed a fresh one. Module-level so tests can shrink them.
 _FALLBACK_3MF_RETRY_DELAYS_SECONDS: tuple[float, ...] = (310.0, 620.0)
 _FALLBACK_3MF_RETRY_DELAYS_SECONDS: tuple[float, ...] = (310.0, 620.0)
 
 
+# Retry ladder for the other temporary give-up: the file service answered and
+# the transfer still did not finish, which at print start is usually the printer
+# serving MQTT, the camera and a job upload at the same time (#3063). Nothing has
+# to expire here, so the first attempt comes early -- #3063's reporter had the
+# same 19MB file complete 48 seconds after the download budget ran out. The later
+# two cover a printer that stays busy well into the print.
+_FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS: tuple[float, ...] = (60.0, 240.0, 600.0)
+
 # printer_id -> the in-flight retry task, so print completion can cancel it.
 # printer_id -> the in-flight retry task, so print completion can cancel it.
 _fallback_3mf_retry_tasks: dict[int, asyncio.Task] = {}
 _fallback_3mf_retry_tasks: dict[int, asyncio.Task] = {}
 
 
@@ -3225,16 +3274,36 @@ async def try_recover_fallback_archive(printer_id: int, name: str, path: Path) -
         return False
         return False
 
 
 
 
-def _schedule_fallback_3mf_retry(printer_id: int, archive_id: int, filenames: list[str]) -> None:
-    """Re-attempt the 3MF download after the printer's FTPS cool-off clears."""
+def _schedule_fallback_3mf_retry(
+    printer_id: int,
+    archive_id: int,
+    filenames: list[str],
+    delays: tuple[float, ...] | None = None,
+    reason: str = REASON_FTPS_COOLOFF,
+) -> None:
+    """Re-attempt the 3MF download after a temporary give-up.
+
+    ``reason`` says which give-up this is, and picks the default ladder: an
+    FTPS cool-off has to be waited out, while a transfer that timed out under
+    contention is worth asking about again straight away (#3063). It is only
+    read for the ladder and the log line -- the retry itself is identical, since
+    in both cases the file is on the printer and the last attempt at it failed
+    for a reason that does not last.
+    """
 
 
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
+    if delays is None:
+        delays = (
+            _FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS
+            if reason == REASON_FTP_TRANSFER_FAILED
+            else _FALLBACK_3MF_RETRY_DELAYS_SECONDS
+        )
 
 
     async def _retry() -> None:
     async def _retry() -> None:
         from backend.app.models.archive import PrintArchive
         from backend.app.models.archive import PrintArchive
         from backend.app.models.printer import Printer
         from backend.app.models.printer import Printer
 
 
-        for delay in _FALLBACK_3MF_RETRY_DELAYS_SECONDS:
+        for delay in delays:
             await asyncio.sleep(delay)
             await asyncio.sleep(delay)
 
 
             async with async_session() as db:
             async with async_session() as db:
@@ -3318,9 +3387,11 @@ def _schedule_fallback_3mf_retry(printer_id: int, archive_id: int, filenames: li
     task = asyncio.create_task(_guarded())
     task = asyncio.create_task(_guarded())
     _fallback_3mf_retry_tasks[printer_id] = task
     _fallback_3mf_retry_tasks[printer_id] = task
     logger.info(
     logger.info(
-        "[RECOVER] Archive %s has no 3MF because printer %s was in its FTPS cool-off; will retry",
+        "[RECOVER] Archive %s has no 3MF (%s) and the file should still be on printer %s; will retry in %s",
         archive_id,
         archive_id,
+        reason,
         printer_id,
         printer_id,
+        ", ".join(f"{d:g}s" for d in delays),
     )
     )
 
 
 
 
@@ -4020,6 +4091,23 @@ async def on_print_start(printer_id: int, data: dict):
         # in minutes with the file still sitting on the printer.
         # in minutes with the file still sitting on the printer.
         blocked_by_ftps_cooloff = False
         blocked_by_ftps_cooloff = False
 
 
+        # Set when a probe reached the printer and still came back without the
+        # file -- a timeout mid-transfer, a refused connection, anything that is
+        # not a clean "not here". A 550 raises FileNotOnPrinterError and is
+        # caught by name below, so a file that genuinely is not on the card
+        # leaves this False and schedules nothing. Anything else means the
+        # transfer, not the file, is what failed, and that does not last (#3063).
+        ftp_transfer_failed = False
+
+        # The print's name, for a fallback archive whose `subtask_name` the
+        # plate guard below had to disown. Display only, and deliberately kept
+        # apart from `subtask_name`: that variable is what every file lookup
+        # here is built from, and once a name has been shown to fetch another
+        # plate's 3MF it must not key `_active_prints` either, or the cover
+        # endpoint hands the same contradicted file to
+        # `_recover_fallback_archive` and fills the row in with it (#3126).
+        display_name_after_plate_reject: str | None = None
+
         # Get FTP retry settings
         # Get FTP retry settings
         ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
         ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
 
@@ -4159,11 +4247,17 @@ async def on_print_start(printer_id: int, data: dict):
                         # runs next) doesn't refetch the same 36MB over FTP.
                         # runs next) doesn't refetch the same 36MB over FTP.
                         cache_3mf_download(printer_id, try_filename, temp_path)
                         cache_3mf_download(printer_id, try_filename, temp_path)
                         break
                         break
+                    # with_ftp_retry returns None once it has spent its budget,
+                    # and download_file_async returns False on a timeout, so an
+                    # exhausted transfer arrives here rather than as an
+                    # exception (#3063).
+                    ftp_transfer_failed = True
                 except FileNotOnPrinterError:
                 except FileNotOnPrinterError:
                     # 550 — file isn't at this path. Advance to next candidate
                     # 550 — file isn't at this path. Advance to next candidate
                     # without burning the retry budget.
                     # without burning the retry budget.
                     logger.debug("3MF not at %s (550), trying next path", remote_path)
                     logger.debug("3MF not at %s (550), trying next path", remote_path)
                 except Exception as e:
                 except Exception as e:
+                    ftp_transfer_failed = True
                     logger.debug("FTP download failed for %s: %s", remote_path, e)
                     logger.debug("FTP download failed for %s: %s", remote_path, e)
 
 
             if downloaded_filename or ftps_handshake_blocked(printer.ip_address):
             if downloaded_filename or ftps_handshake_blocked(printer.ip_address):
@@ -4236,6 +4330,9 @@ async def on_print_start(printer_id: int, data: dict):
                                 logger.info("Found and downloaded from %s: %s", search_dir, fname)
                                 logger.info("Found and downloaded from %s: %s", search_dir, fname)
                                 cache_3mf_download(printer_id, fname, temp_path)
                                 cache_3mf_download(printer_id, fname, temp_path)
                                 break
                                 break
+                            # The listing named the file, so it is on the card;
+                            # only the transfer failed (#3063).
+                            ftp_transfer_failed = True
                 except Exception as e:
                 except Exception as e:
                     logger.debug("Failed to list %s: %s", search_dir, e)
                     logger.debug("Failed to list %s: %s", search_dir, e)
 
 
@@ -4340,13 +4437,33 @@ async def on_print_start(printer_id: int, data: dict):
                         pass
                         pass
                     temp_path = None
                     temp_path = None
                     downloaded_filename = None
                     downloaded_filename = None
-                    # Override the stale subtask_name so the fallback archive's
-                    # print_name reflects the correct plate. Prefer the swapped
-                    # name when we have one; otherwise let filename win.
-                    if corrected_subtask:
-                        subtask_name = corrected_subtask
-                    else:
-                        subtask_name = ""
+                    # Whatever the sweep's transport did earlier, it is not why
+                    # this archive ends up empty: a 3MF downloaded fine, it was
+                    # just the wrong plate. Retrying would re-fetch that same
+                    # contradicted file under the same stale names and hand it
+                    # to _recover_fallback_archive, which checks that a
+                    # candidate is a readable 3MF but not which plate it is --
+                    # so the row would be filled in with another plate's
+                    # filament and cost, the exact swap #2957 removed (#3063).
+                    ftp_transfer_failed = False
+                    # Disown the name for *lookups*: it has just been shown to
+                    # fetch another plate's 3MF, and it keys `_active_prints`
+                    # below, where the cover endpoint's own download of that
+                    # same name would find this archive and fill it in with the
+                    # file we are discarding here.
+                    #
+                    # Keep it for the *title*, which is a separate question.
+                    # ``swap_plate_suffix`` returns None both for a name that
+                    # carries no "- Plate N" / "_plate_N" suffix and for no
+                    # name at all, and those are not the same situation: a name
+                    # without a suffix holds no stale plate number to be wrong
+                    # about. Blanking both uses at once dropped the project
+                    # name too, and the row fell through to the gcode_file path
+                    # titled "plate_1" though the real name was in hand.
+                    # #1204's own premise is consecutive plates *of the same
+                    # model*, so the project part is right either way (#3126).
+                    display_name_after_plate_reject = corrected_subtask or subtask_name or None
+                    subtask_name = corrected_subtask or ""
 
 
         if not downloaded_filename or not temp_path:
         if not downloaded_filename or not temp_path:
             logger.warning("Could not find 3MF file for print: %s", filename or subtask_name)
             logger.warning("Could not find 3MF file for print: %s", filename or subtask_name)
@@ -4355,8 +4472,25 @@ async def on_print_start(printer_id: int, data: dict):
             try:
             try:
                 from backend.app.models.archive import PrintArchive
                 from backend.app.models.archive import PrintArchive
 
 
-                # Derive print name from subtask_name or filename
-                print_name = subtask_name or filename
+                # Why the card is empty. The two temporary causes outrank the
+                # storage verdict because they say the sweep never got a fair
+                # answer: a cool-off skipped it at the transport, and a failed
+                # transfer reached the printer but never finished. Either way
+                # the file is still on the card, so reporting where the printer
+                # files its jobs would describe a setting that is not the
+                # problem (#2957, #3063).
+                if blocked_by_ftps_cooloff:
+                    no_3mf_reason = REASON_FTPS_COOLOFF
+                elif storage.reachable and ftp_transfer_failed:
+                    no_3mf_reason = REASON_FTP_TRANSFER_FAILED
+                else:
+                    no_3mf_reason = storage.reason
+
+                # Derive print name from subtask_name or filename. The
+                # plate guard's disowned name comes second: it is a real name
+                # for a real print, and only the gcode_file path is left
+                # otherwise -- which titles the row "plate_1" (#3126).
+                print_name = subtask_name or display_name_after_plate_reject or filename
                 if print_name:
                 if print_name:
                     # Clean up the name (remove extensions, path parts)
                     # Clean up the name (remove extensions, path parts)
                     print_name = print_name.split("/")[-1]
                     print_name = print_name.split("/")[-1]
@@ -4397,15 +4531,12 @@ async def on_print_start(printer_id: int, data: dict):
                     filament_color=mqtt_filament_meta.get("filament_color"),
                     filament_color=mqtt_filament_meta.get("filament_color"),
                     extra_data={
                     extra_data={
                         "no_3mf_available": True,
                         "no_3mf_available": True,
-                        # Why the card is empty, when we know. The banner reads
-                        # this to stop telling H2/P2 owners to switch on a
-                        # setting that is already on and would not help (#2780).
-                        # A cool-off outranks the storage verdict: the sweep was
-                        # skipped at the transport, so the verdict never got to
-                        # be tested, and reporting it would blame the SD card
-                        # for a TLS handshake (#2957).
-                        "no_3mf_reason": REASON_FTPS_COOLOFF if blocked_by_ftps_cooloff else storage.reason,
-                        "original_subtask": subtask_name,
+                        # Why the card is empty, when we know -- see above. The
+                        # banner reads this to stop telling H2/P2 owners to
+                        # switch on a setting that is already on and would not
+                        # have helped (#2780).
+                        "no_3mf_reason": no_3mf_reason,
+                        "original_subtask": subtask_name or display_name_after_plate_reject or "",
                         "_print_data": data,
                         "_print_data": data,
                     },
                     },
                 )
                 )
@@ -4465,13 +4596,15 @@ async def on_print_start(printer_id: int, data: dict):
                 except Exception as e:
                 except Exception as e:
                     logger.debug("[SPOOLMAN] Could not store tracking for fallback archive: %s", e)
                     logger.debug("[SPOOLMAN] Could not store tracking for fallback archive: %s", e)
 
 
-                # A cool-off give-up is temporary and the file is on the
-                # printer — come back for it once the handshake block clears
-                # (#2957). Deliberately not scheduled for a storage verdict:
-                # a file on internal eMMC will not appear at any FTPS path
-                # however long we wait, and retrying it is exactly the sweep
-                # #2780 removed.
-                if blocked_by_ftps_cooloff and possible_names:
+                # Both temporary give-ups are worth coming back for, and for
+                # the same reason: the file is on the printer and the last look
+                # failed at the transport rather than finding nothing. One waits
+                # out the handshake block (#2957), the other waits for the
+                # printer to stop being busy (#3063). Deliberately not scheduled
+                # for a storage verdict: a file on internal eMMC will not appear
+                # at any FTPS path however long we wait, and retrying it is
+                # exactly the sweep #2780 removed.
+                if no_3mf_reason in (REASON_FTPS_COOLOFF, REASON_FTP_TRANSFER_FAILED) and possible_names:
                     # `possible_names`, not the raw MQTT strings: it is the exact
                     # `possible_names`, not the raw MQTT strings: it is the exact
                     # list this flow just tried, already stripped of any path
                     # list this flow just tried, already stripped of any path
                     # (`filename` arrives as "/data/Metadata/plate_1.gcode" on
                     # (`filename` arrives as "/data/Metadata/plate_1.gcode" on
@@ -4480,6 +4613,7 @@ async def on_print_start(printer_id: int, data: dict):
                         printer_id=printer_id,
                         printer_id=printer_id,
                         archive_id=fallback_archive.id,
                         archive_id=fallback_archive.id,
                         filenames=list(possible_names),
                         filenames=list(possible_names),
+                        reason=no_3mf_reason,
                     )
                     )
 
 
                 # Send notification without archive data (file not found)
                 # Send notification without archive data (file not found)
@@ -7713,6 +7847,11 @@ _ams_cleanup_counter = 0  # Track recordings to trigger periodic cleanup
 # Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
 # Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
 _ams_alarm_cooldown: dict[str, datetime] = {}
 _ams_alarm_cooldown: dict[str, datetime] = {}
 AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
 AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
+# (printer_id, ams_id) already reported as sending the drop index and no
+# percentage. Logged once each so a supported printer that turns out to do this
+# shows up in a support bundle rather than as a user wondering where the
+# humidity reading went -- see the note at the read site below (#3140).
+_ams_index_only_logged: set[tuple[int, int]] = set()
 
 
 
 
 def _resolve_temp_alarm_threshold(fair_threshold: float, raw_alarm_value: str | None) -> float:
 def _resolve_temp_alarm_threshold(fair_threshold: float, raw_alarm_value: str | None) -> float:
@@ -7950,20 +8089,30 @@ async def record_ams_history():
                     for ams_data in raw_data["ams"]:
                     for ams_data in raw_data["ams"]:
                         ams_id = int(ams_data.get("id", 0))
                         ams_id = int(ams_data.get("id", 0))
 
 
-                        # Get humidity (prefer humidity_raw)
-                        humidity_raw = ams_data.get("humidity_raw")
-                        humidity_idx = ams_data.get("humidity")
-                        humidity = None
-                        if humidity_raw is not None:
-                            try:
-                                humidity = float(humidity_raw)
-                            except (ValueError, TypeError):
-                                pass  # Skip unparseable humidity; will try fallback
-                        if humidity is None and humidity_idx is not None:
-                            try:
-                                humidity = float(humidity_idx)
-                            except (ValueError, TypeError):
-                                pass  # Skip unparseable humidity index value
+                        # Percentage only. The 1-5 index is inverted, so
+                        # charting it as a percentage drew the wettest units as
+                        # the driest (#3140); a unit that reports no percentage
+                        # leaves a gap in the chart instead. See
+                        # utils/ams_humidity.
+                        humidity = ams_humidity_percent(ams_data)
+
+                        # No supported printer is known to send the index
+                        # alone -- the report came from unsupported firmware,
+                        # and no install has been seen using the old fallback.
+                        # "Known" is doing work there, so say so once per unit:
+                        # the alternative is a silent blank card.
+                        if humidity is None and ams_data.get("humidity") is not None:
+                            unit_key = (printer.id, ams_id)
+                            if unit_key not in _ams_index_only_logged:
+                                _ams_index_only_logged.add(unit_key)
+                                logger.info(
+                                    "[%s] AMS %d reports the 1-5 humidity index but no usable humidity_raw "
+                                    "percentage. The index is inverted and is not shown as a percentage "
+                                    "(#3140), so this unit has no humidity reading, chart or alarm. "
+                                    "Please report this with the printer and AMS firmware versions.",
+                                    printer.name,
+                                    ams_id,
+                                )
 
 
                         # Get temperature
                         # Get temperature
                         temperature = None
                         temperature = None
@@ -7983,7 +8132,12 @@ async def record_ams_history():
                             printer_id=printer.id,
                             printer_id=printer.id,
                             ams_id=ams_id,
                             ams_id=ams_id,
                             humidity=humidity,
                             humidity=humidity,
-                            humidity_raw=float(humidity_raw) if humidity_raw else None,
+                            # Both columns hold the same reading now that the
+                            # index can no longer reach ``humidity``. Writing it
+                            # through the same value also stops a genuine 0%
+                            # from being stored as NULL, which the old truthiness
+                            # test did.
+                            humidity_raw=humidity,
                             temperature=temperature,
                             temperature=temperature,
                         )
                         )
                         db.add(history)
                         db.add(history)
@@ -8815,7 +8969,7 @@ async def lifespan(app: FastAPI):
     import httpx as _httpx
     import httpx as _httpx
 
 
     from backend.app.services.bambu_cloud import set_shared_http_client
     from backend.app.services.bambu_cloud import set_shared_http_client
-    from backend.app.services.makerworld import (
+    from backend.app.services.model_providers.makerworld.service import (
         set_shared_http_client as set_shared_makerworld_http_client,
         set_shared_http_client as set_shared_makerworld_http_client,
     )
     )
     from backend.app.services.orca_cloud import (
     from backend.app.services.orca_cloud import (
@@ -9375,6 +9529,11 @@ PUBLIC_API_PATTERNS = [
     # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
     # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
     # download token in the URL path instead.
     # download token in the URL path instead.
     "/dl/",  # /archives/{id}/dl/{token}/{filename}, /library/files/{id}/dl/{token}/{filename}
     "/dl/",  # /archives/{id}/dl/{token}/{filename}, /library/files/{id}/dl/{token}/{filename}
+    # Same family, but the segment is "source-dl" — which does NOT contain "/dl/",
+    # and these patterns match by substring. Without its own entry the middleware
+    # 401s the slicer's header-less request before the route's token check runs,
+    # so "Open source 3MF in slicer" failed whenever auth was enabled (#3029).
+    "/source-dl/",  # /archives/{id}/source-dl/{token}/{filename}
     # Obico ML API fetches JPEG frames by one-shot nonce (issue #172 follow-up).
     # Obico ML API fetches JPEG frames by one-shot nonce (issue #172 follow-up).
     # The nonce itself is the credential: 32-byte random, single-use, ~30s TTL.
     # The nonce itself is the credential: 32-byte random, single-use, ~30s TTL.
     "/obico/cached-frame/",  # /obico/cached-frame/{nonce}
     "/obico/cached-frame/",  # /obico/cached-frame/{nonce}

+ 7 - 1
backend/app/models/finance.py

@@ -38,6 +38,13 @@ class UserWallet(Base):
     """Per-user wallet balance.
     """Per-user wallet balance.
 
 
     Balance updates are driven by wallet transactions.
     Balance updates are driven by wallet transactions.
+
+    No currency column: an install has exactly one currency, held in the
+    ``currency`` app setting, and nothing here converts between currencies. The
+    column that used to sit on this table recorded whatever was configured when
+    the row happened to be created, three of the four writers hardcoded "EUR"
+    into it, and the Finance page rendered what it found -- so an install set
+    to AUD reported euros (#3123).
     """
     """
 
 
     __tablename__ = "user_wallets"
     __tablename__ = "user_wallets"
@@ -45,7 +52,6 @@ class UserWallet(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
     user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
     balance: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False), default=0.0)
     balance: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False), default=0.0)
-    currency: Mapped[str] = mapped_column(String(3), default="EUR")
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
     user: Mapped[User] = relationship()
     user: Mapped[User] = relationship()

+ 23 - 1
backend/app/models/library.py

@@ -25,7 +25,29 @@ class LibraryFolder(Base):
 
 
     # Link to project or archive
     # Link to project or archive
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
-    archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
+    # use_alter breaks a dependency cycle in the schema, and is not about this
+    # link being special: print_archives.library_file_id -> library_files,
+    # library_files.folder_id -> library_folders, and this column back to
+    # print_archives. Each is reasonable alone and together they are a loop
+    # SQLAlchemy cannot topologically sort, so metadata.sorted_tables dropped
+    # those edges, warned on every backup and restore, and could hand back an
+    # order placing a child before its parent -- which once imported
+    # library_files ahead of library_folders and killed a restore on a foreign
+    # key violation. Marking ONE edge for ALTER removes it from the sort graph
+    # and the other two order correctly. The constraint is still created and
+    # still enforced: PostgreSQL emits it as ALTER TABLE ADD CONSTRAINT (as it
+    # already does for every constraint on these three tables), and SQLite,
+    # which reports no ALTER support, inlines it into CREATE TABLE as before.
+    # It has to be named, because an unnamed constraint cannot be ALTERed in.
+    archive_id: Mapped[int | None] = mapped_column(
+        ForeignKey(
+            "print_archives.id",
+            ondelete="SET NULL",
+            use_alter=True,
+            name="fk_library_folders_archive_id",
+        ),
+        nullable=True,
+    )
 
 
     # Timestamps
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 5 - 1
backend/app/schemas/archive.py

@@ -18,7 +18,11 @@ class ArchiveBase(BaseModel):
     notes: str | None = None
     notes: str | None = None
     cost: float | None = None
     cost: float | None = None
     failure_reason: str | None = None
     failure_reason: str | None = None
-    quantity: int | None = None  # Number of items printed
+    # Number of items printed. 0 is a legal answer -- a plate that jammed and
+    # came off ruined produced nothing, and the project's completed-items count
+    # sums this column (#3051). Bounded for the same reason as the grams below:
+    # it feeds project totals, and a negative would subtract from them.
+    quantity: Annotated[int | None, Field(ge=0, le=10_000)] = None
     # User-defined link (Printables, Thingiverse, etc.)
     # User-defined link (Printables, Thingiverse, etc.)
     external_url: str | None = None
     external_url: str | None = None
 
 

+ 7 - 0
backend/app/schemas/library.py

@@ -325,6 +325,13 @@ class AddToQueueRequest(BaseModel):
     """Schema for adding library files to the print queue."""
     """Schema for adding library files to the print queue."""
 
 
     file_ids: list[int] = Field(..., min_length=1)
     file_ids: list[int] = Field(..., min_length=1)
+    # Where the items should go. Mutually exclusive, both optional. With
+    # neither, each file's own declared model is used when a printer of that
+    # model is active: an item carrying no printer and no target model matches
+    # neither branch of the scheduler's dispatch, so it is one nothing can ever
+    # pick up (#3112).
+    printer_id: int | None = None
+    target_model: str | None = None
 
 
 
 
 class AddToQueueResult(BaseModel):
 class AddToQueueResult(BaseModel):

+ 10 - 0
backend/app/schemas/makerworld.py

@@ -42,6 +42,16 @@ class MakerWorldImportRequest(BaseModel):
         ...,
         ...,
         description="The MakerWorld design ID (the number in /models/{id}).",
         description="The MakerWorld design ID (the number in /models/{id}).",
     )
     )
+    source_type: str = Field(
+        default="makerworld",
+        description=(
+            "Which registered model provider owns the resource. Import "
+            "identifies a model by numeric id rather than URL, so there is no "
+            "URL for the provider registry to route on — the caller names the "
+            "provider instead. Defaults to 'makerworld' so existing callers "
+            "stay unchanged."
+        ),
+    )
     profile_id: int | None = Field(
     profile_id: int | None = Field(
         default=None,
         default=None,
         description=(
         description=(

+ 5 - 3
backend/app/schemas/notification.py

@@ -301,9 +301,11 @@ class NtfyConfig(BaseModel):
     event_priorities: dict[str, int] | None = Field(
     event_priorities: dict[str, int] | None = Field(
         default=None,
         default=None,
         description=(
         description=(
-            "Per-event priority override. Keys are event names (e.g. 'on_print_failed'); "
-            "values are ntfy priorities 1-5 (1=min, 2=low, 3=default, 4=high, 5=urgent). "
-            "Events without an entry use ntfy's server-side default."
+            "Per-event priority override. Keys are event names, either the provider's "
+            "toggle column ('on_print_failed', what the UI writes) or the bare event "
+            "name ('print_failed'); both are accepted. Values are ntfy priorities 1-5 "
+            "(1=min, 2=low, 3=default, 4=high, 5=urgent). Events without an entry use "
+            "ntfy's server-side default."
         ),
         ),
     )
     )
 
 

+ 1 - 1
backend/app/schemas/print_queue.py

@@ -187,7 +187,7 @@ class PrintQueueItemResponse(BaseModel):
     target_location: str | None = None  # Target location filter for model-based assignment
     target_location: str | None = None  # Target location filter for model-based assignment
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     filament_overrides: list[dict] | None = None  # Filament overrides for model-based assignment
     filament_overrides: list[dict] | None = None  # Filament overrides for model-based assignment
-    waiting_reason: str | None = None  # Why a model-based job hasn't started yet
+    waiting_reason: str | None = None  # Why this job hasn't started yet (empty once it can)
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     library_file_id: int | None  # For queue items from library files
     library_file_id: int | None  # For queue items from library files
     cost_center_id: int | None = None
     cost_center_id: int | None = None

+ 6 - 2
backend/app/schemas/printer.py

@@ -446,10 +446,14 @@ class PrinterStatus(BaseModel):
 class DiagnosticCheck(BaseModel):
 class DiagnosticCheck(BaseModel):
     """One connection-diagnostic check result.
     """One connection-diagnostic check result.
 
 
-    ``id`` is a stable key (port_mqtt, port_ftps, port_rtsps, network_mode,
-    subnet, mqtt_auth, developer_mode); the frontend renders the localized
+    ``id`` is a stable key (port_mqtt, port_ftps, port_rtsps,
+    macos_local_network, network_mode, subnet, external_storage, mqtt_auth,
+    developer_mode, printer_publishing); the frontend renders the localized
     title and fix text from id + status. ``params`` carries interpolation
     title and fix text from id + status. ``params`` carries interpolation
     values (e.g. network mode, IP addresses) for that text.
     values (e.g. network mode, IP addresses) for that text.
+
+    Not every check is emitted on every run: ``macos_local_network`` appears
+    only on macOS, where it is the only platform it can say anything about.
     """
     """
 
 
     id: str
     id: str

+ 60 - 0
backend/app/services/ams_slot_presence.py

@@ -0,0 +1,60 @@
+"""Is there a spool in this AMS slot?
+
+Three backend decisions turn on the answer -- whether to push
+``ams_filament_setting`` when a spool is assigned, whether a pre-assigned slot
+has just been filled, and whether an assignment has gone stale -- and all three
+used to read it off the tray's ``state`` field. That field cannot carry it.
+
+## Why ``state`` is the wrong source
+
+``state`` is firmware-variant. The A1 Mini BMCU and the P1S Standard AMS report
+3 for a loaded slot and never emit 11; the AMS-HT's codes differ again (#2670).
+Bambuddy already works around that on the printer card, where
+``getEmptySlotKind`` (``PrintersPage.tsx``) reads the presence bit first and
+only falls back to the 9/10 heuristic when there is no bit to read.
+
+Worse, ``state`` is partly Bambuddy's own writing. ``apply_tray_exist_bits``
+sets ``state = 9`` on every slot whose presence bit is 0 -- and when the bit
+comes back it leaves the 9 exactly where it was, because the "slot occupied"
+branch only annotates ``exists`` and moves on. So a slot the firmware says is
+full can sit in the cache reading ``exists=True, state=9`` indefinitely. That
+is #3084: a non-Bambu spool is swapped in, Assign Spool reads the stale 9,
+calls the slot empty, sends no MQTT, and the printer keeps showing ``?``. The
+deferred-configuration replay could not rescue it either, because its own
+"loaded" test was the same 9/10 heuristic -- the exact deadlock #1322 removed
+elsewhere. #3100 is the same stale 9 one step further on: the replay does not
+fire, the assignment keeps the empty fingerprint it was stored with, and the
+first real tray report is read as a spool swap and deleted.
+
+## What this module answers
+
+``tray_exist_bits`` is the firmware's own "which slots have a spool" bitmask --
+the one BambuStudio draws its ``?`` from -- and ``apply_tray_exist_bits``
+records it per tray as ``exists``. That bit is authoritative where it exists,
+and absent otherwise; it is never a guess. Callers that want a decision for a
+payload carrying no bit at all keep their own fallback, because the right
+fallback differs per caller: the assign path wants to know whether the push is
+doomed, the unlink pass wants to know whether a spool was removed, and a state
+of 26 ("unloaded", mid-runout) answers those two questions differently.
+"""
+
+from collections.abc import Mapping
+from typing import Any
+
+
+def spool_present(tray: Mapping[str, Any] | None) -> bool | None:
+    """Does firmware's presence bit say a spool is in this slot?
+
+    ``True`` / ``False`` straight from ``tray_exist_bits``; ``None`` when the
+    tray carries no presence annotation, which means the caller has to decide
+    on its own terms rather than assume either way.
+
+    Only the internal AMS path annotates ``exists`` (``apply_tray_exist_bits``
+    is called with ``annotate_exists=True`` there and False for the VP bridge),
+    so the external spool's ``vt_tray`` entries answer ``None`` -- they have no
+    bit in the mask.
+    """
+    if not isinstance(tray, Mapping):
+        return None
+    exists = tray.get("exists")
+    return exists if isinstance(exists, bool) else None

+ 118 - 0
backend/app/services/bambu_cloud_credentials.py

@@ -0,0 +1,118 @@
+"""Bambu Cloud credential storage.
+
+Single seam for reading and bookkeeping the stored Bambu Cloud bearer token:
+per-user columns when auth is enabled, global ``Settings`` rows otherwise
+(auth-disabled single-user installs). Lives in the services layer so feature
+packages (e.g. ``model_providers``) can consume credentials without importing
+the route layer — routes are just one consumer among several here.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timezone
+
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import async_session
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+
+logger = logging.getLogger(__name__)
+
+# Keys for storing cloud credentials in settings
+CLOUD_TOKEN_KEY = "bambu_cloud_token"
+CLOUD_EMAIL_KEY = "bambu_cloud_email"
+CLOUD_REGION_KEY = "bambu_cloud_region"
+# Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
+# an ISO timestamp; absent/empty means "not known to be dead".
+CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
+
+
+def _normalise_region(region: str | None) -> str:
+    """Treat NULL/empty as 'global' for legacy rows that predate the region column."""
+    return region if region in ("global", "china") else "global"
+
+
+async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
+    """Whether the stored Bambu token is known to have been rejected.
+
+    Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
+    cleared on a fresh login/logout. This is the only durable record we have:
+    Bambu's access token is opaque (no readable expiry) and Bambuddy does not
+    persist the refresh token, so without this flag a dead credential looks
+    exactly like a live one.
+    """
+    if user is not None:
+        return user.cloud_token_invalid_at is not None
+    result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+    row = result.scalar_one_or_none()
+    return bool(row and row.value)
+
+
+async def mark_cloud_token_invalid(user_id: int | None) -> None:
+    """Record that Bambu rejected the stored token.
+
+    Opens its own session on purpose. This runs from
+    ``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
+    is about to fail — writing through that route's session would tie the flag
+    to a transaction the route may still roll back, and the fact that the
+    credential is dead is true regardless of how the request ends.
+
+    Best-effort: a bookkeeping failure must never replace the 401 the caller
+    actually needs to see. ``user_id=None`` (auth-disabled single-user setup)
+    records the global flag — those installs *do* hold a token
+    (:func:`get_stored_token` reads it from ``Settings``), so the rejection
+    must land somewhere the status endpoints can see it.
+    """
+    now = datetime.now(timezone.utc)
+    try:
+        async with async_session() as db:
+            if user_id is not None:
+                await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
+            else:
+                result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+                row = result.scalar_one_or_none()
+                if row:
+                    row.value = now.isoformat()
+                else:
+                    db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
+            await db.commit()
+        logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
+    except Exception:
+        logger.exception("Could not record the Bambu Cloud token as invalid")
+
+
+async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
+    """Clear the rejected-token flag — called on every fresh login and logout."""
+    if user is not None:
+        await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
+        return
+    result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+    row = result.scalar_one_or_none()
+    if row:
+        await db.delete(row)
+
+
+async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
+    """Get stored cloud token, email, and region.
+
+    When a user is provided (auth enabled), returns that user's per-user credentials.
+    When user is None (auth disabled), falls back to global Settings table.
+    Region defaults to ``"global"`` when unset (including for rows that predate the
+    ``cloud_region`` column).
+    """
+    if user is not None:
+        return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)
+
+    # Fallback: global storage (auth disabled)
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
+    )
+    settings = {s.key: s.value for s in result.scalars().all()}
+    return (
+        settings.get(CLOUD_TOKEN_KEY),
+        settings.get(CLOUD_EMAIL_KEY),
+        _normalise_region(settings.get(CLOUD_REGION_KEY)),
+    )

+ 51 - 7
backend/app/services/bambu_ftp.py

@@ -554,6 +554,9 @@ class BambuFTPClient:
         # operation and cannot be overwritten by work against another printer.
         # operation and cannot be overwritten by work against another printer.
         self.last_failure: FtpFailure | None = None
         self.last_failure: FtpFailure | None = None
         self._ftp: ImplicitFTP_TLS | None = None
         self._ftp: ImplicitFTP_TLS | None = None
+        # When the control socket to the printer was opened, so the close log
+        # can say how long the session was held (#3009).
+        self._connected_at: float | None = None
 
 
     def _is_a1_model(self) -> bool:
     def _is_a1_model(self) -> bool:
         """Check if this is an A1 series printer."""
         """Check if this is an A1 series printer."""
@@ -656,6 +659,10 @@ class BambuFTPClient:
                 cap_tls_v1_2=profile.cap_tls_v1_2,
                 cap_tls_v1_2=profile.cap_tls_v1_2,
             )
             )
             self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
             self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
+            # Stamped here rather than after login: the socket exists from this
+            # point on, and a session that dies during login is exactly the one
+            # whose lifetime someone reading the log wants accounted for.
+            self._connected_at = time.monotonic()
             logger.debug("FTP connected, logging in as bblp")
             logger.debug("FTP connected, logging in as bblp")
             self._ftp.login("bblp", self.access_code)
             self._ftp.login("bblp", self.access_code)
             if use_prot_c:
             if use_prot_c:
@@ -677,12 +684,12 @@ class BambuFTPClient:
         except ftplib.error_perm as e:
         except ftplib.error_perm as e:
             logger.warning("FTP connection permission error to %s: %s", self.ip_address, e)
             logger.warning("FTP connection permission error to %s: %s", self.ip_address, e)
             self.last_failure = FtpFailure(FtpFailureKind.AUTH, str(e), _ftp_reply_code(e))
             self.last_failure = FtpFailure(FtpFailureKind.AUTH, str(e), _ftp_reply_code(e))
-            self._abandon_connection()
+            self._abandon_connection("login rejected")
             return False
             return False
         except TimeoutError as e:
         except TimeoutError as e:
             logger.warning("FTP connection timed out to %s: %s", self.ip_address, e)
             logger.warning("FTP connection timed out to %s: %s", self.ip_address, e)
             self.last_failure = FtpFailure(FtpFailureKind.TIMEOUT, str(e))
             self.last_failure = FtpFailure(FtpFailureKind.TIMEOUT, str(e))
-            self._abandon_connection()
+            self._abandon_connection("connect timed out")
             return False
             return False
         except ssl.SSLError as e:
         except ssl.SSLError as e:
             # Not a transient failure and not something another path or another
             # Not a transient failure and not something another path or another
@@ -711,7 +718,7 @@ class BambuFTPClient:
             # holding a failed handshake open across that is the exact thing
             # holding a failed handshake open across that is the exact thing
             # #2780's cleanup was added to stop. Idempotent, so the call that
             # #2780's cleanup was added to stop. Idempotent, so the call that
             # used to sit at the end of this branch simply moved up.
             # used to sit at the end of this branch simply moved up.
-            self._abandon_connection()
+            self._abandon_connection("TLS handshake failed")
 
 
             # Ask the printer what it actually said, once per cool-off window.
             # Ask the printer what it actually said, once per cool-off window.
             # Checked before the deadline below is written, so a live entry here
             # Checked before the deadline below is written, so a live entry here
@@ -743,10 +750,21 @@ class BambuFTPClient:
         except (OSError, ftplib.Error) as e:
         except (OSError, ftplib.Error) as e:
             logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
             logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
             self.last_failure = FtpFailure(FtpFailureKind.NETWORK, str(e), _ftp_reply_code(e))
             self.last_failure = FtpFailure(FtpFailureKind.NETWORK, str(e), _ftp_reply_code(e))
-            self._abandon_connection()
+            self._abandon_connection("connect failed")
             return False
             return False
 
 
-    def _abandon_connection(self) -> None:
+    def _held_for(self) -> str:
+        """How long the control socket has been open, for the close log.
+
+        "unknown" when :meth:`connect` never got as far as opening one -- the
+        cool-off skip and a DNS/refused failure both land in
+        :meth:`_abandon_connection` without a socket ever existing.
+        """
+        if self._connected_at is None:
+            return "unknown"
+        return f"{time.monotonic() - self._connected_at:.1f}s"
+
+    def _abandon_connection(self, reason: str = "connection never became usable") -> None:
         """Drop a connection that never became usable, closing its socket.
         """Drop a connection that never became usable, closing its socket.
 
 
         Every failure path in :meth:`connect` used to clear ``self._ftp`` and
         Every failure path in :meth:`connect` used to clear ``self._ftp`` and
@@ -765,24 +783,50 @@ class BambuFTPClient:
         """
         """
         ftp = self._ftp
         ftp = self._ftp
         self._ftp = None
         self._ftp = None
+        held = self._held_for()
+        self._connected_at = None
         if ftp is None:
         if ftp is None:
             return
             return
         try:
         try:
             ftp.close()
             ftp.close()
         except (OSError, ftplib.Error, EOFError):
         except (OSError, ftplib.Error, EOFError):
             pass  # Best-effort; the socket may already be gone
             pass  # Best-effort; the socket may already be gone
+        # See the note in ``disconnect``: every session that opens a socket
+        # says how it closed, so the log carries matched pairs (#3009).
+        logger.debug(
+            "FTP session to %s closed without QUIT (%s), held %s",
+            self.ip_address,
+            reason,
+            held,
+        )
 
 
     def disconnect(self):
     def disconnect(self):
         """Disconnect from the FTP server."""
         """Disconnect from the FTP server."""
         if self._ftp:
         if self._ftp:
+            held = self._held_for()
             try:
             try:
                 self._ftp.quit()
                 self._ftp.quit()
-            except (OSError, ftplib.Error, EOFError):
+            except (OSError, ftplib.Error, EOFError) as e:
                 # ``quit()`` sends QUIT and only then closes; when the send
                 # ``quit()`` sends QUIT and only then closes; when the send
                 # raises, ftplib never reaches its own close and the socket
                 # raises, ftplib never reaches its own close and the socket
                 # stays open. Close it here rather than leaving it to the GC.
                 # stays open. Close it here rather than leaving it to the GC.
-                self._abandon_connection()
+                self._abandon_connection(f"QUIT failed: {e}")
+            else:
+                # One line per session, at DEBUG. Neither this method nor
+                # ``_abandon_connection`` used to log anything at any level, so
+                # a session closed cleanly and a socket genuinely left open
+                # produced identical logs -- nothing. #3009 read that silence
+                # after a print as proof the connections were never closed, and
+                # nothing in the log could have shown otherwise. Now every
+                # connect has a matching close, so the next person can settle it
+                # from a support bundle instead of by inference.
+                logger.debug(
+                    "FTP session to %s closed after QUIT, held %s",
+                    self.ip_address,
+                    held,
+                )
             self._ftp = None
             self._ftp = None
+            self._connected_at = None
 
 
     def list_files(self, path: str = "/", *, raise_on_error: bool = False) -> list[dict]:
     def list_files(self, path: str = "/", *, raise_on_error: bool = False) -> list[dict]:
         """List files in a directory."""
         """List files in a directory."""

+ 75 - 31
backend/app/services/bambu_mqtt.py

@@ -24,6 +24,8 @@ import paho.mqtt.client as mqtt
 from backend.app.services.hms_actions import HMSAction, get_actions_for_error_code
 from backend.app.services.hms_actions import HMSAction, get_actions_for_error_code
 from backend.app.services.hms_errors import describe_fault
 from backend.app.services.hms_errors import describe_fault
 from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES
 from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES
+from backend.app.utils.ams_humidity import ams_humidity_percent
+from backend.app.utils.paho_teardown import retire_paho_client
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -84,6 +86,20 @@ def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
         return None
         return None
 
 
 
 
+def is_printer_status_frame(print_data: dict) -> bool:
+    """True when a ``print`` payload is the printer reporting its own state.
+
+    Bambu firmware echoes a command's fields back in its acknowledgement, so a
+    `project_file` ack carries whatever Bambuddy put on the wire — including
+    the `cfg` bitmask and the per-job `timelapse` flag. Ingesting those as
+    telemetry means reading our own request back as the printer's state
+    (#3040). Only `push_status` (and the odd firmware that omits `command`
+    entirely on a status frame) describes the printer.
+    """
+    command = print_data.get("command")
+    return command is None or command == "push_status"
+
+
 # ── A2L "AMS Lite" unit-id normalisation (issue capture 2026-07-20) ──────────
 # ── A2L "AMS Lite" unit-id normalisation (issue capture 2026-07-20) ──────────
 # The A2L reports its 4-slot AMS Lite as physical unit **id 16**, but the
 # The A2L reports its 4-slot AMS Lite as physical unit **id 16**, but the
 # firmware is internally inconsistent about it:
 # firmware is internally inconsistent about it:
@@ -1596,15 +1612,14 @@ class BambuMQTTClient:
         #     reconnect, mixing stale commands into the next dispatch and
         #     reconnect, mixing stale commands into the next dispatch and
         #     triggering 0500_4003 SD R/W on the printer.
         #     triggering 0500_4003 SD R/W on the printer.
         #
         #
-        # Paho-network-thread callers (line ~2604/~2623 — dev-mode probe and
-        # ams_filament_setting zombie detection inside `_update_state`)
-        #   → socket-close fallback. Calling `loop_stop()` from inside the
-        #     network thread would self-join and deadlock; the safe pattern is
-        #     to close the socket and let paho's own loop detect the broken
-        #     connection and auto-reconnect (same instance, same client_id —
-        #     queue replay is theoretically possible here but those paths have
-        #     always done socket-close and #1136 was specifically triggered
-        #     from the dispatch path).
+        # Paho-network-thread callers (dev-mode probe and ams_filament_setting
+        # zombie detection, both inside `_update_state`)
+        #   → socket-close fallback. There is no running loop on that thread to
+        #     hand the rebuilt client, so close the socket and let paho's own
+        #     loop detect the broken connection and auto-reconnect (same
+        #     instance, same client_id — queue replay is theoretically possible
+        #     here but those paths have always done socket-close and #1136 was
+        #     specifically triggered from the dispatch path).
         logger.warning("[%s] Forcing MQTT reconnect: %s", self.serial_number, reason)
         logger.warning("[%s] Forcing MQTT reconnect: %s", self.serial_number, reason)
         self._stale_reconnecting = True
         self._stale_reconnecting = True
         self.state.connected = False
         self.state.connected = False
@@ -1615,11 +1630,11 @@ class BambuMQTTClient:
     def _reset_client_for_reconnect(self) -> None:
     def _reset_client_for_reconnect(self) -> None:
         """Route between hard-reset and socket-close based on caller thread.
         """Route between hard-reset and socket-close based on caller thread.
 
 
-        Hard-reset (preferred) requires we're not running on paho's network
-        thread, since `loop_stop()` on the same thread deadlocks. Detect via
-        ``asyncio.get_running_loop()`` — paho's callback thread has no loop;
-        every legitimate hard-reset caller (FastAPI handlers, background
-        async tasks) does."""
+        Hard-reset (preferred) rebuilds the client, and the rebuild needs a
+        running loop to hand to ``connect()``. ``asyncio.get_running_loop()``
+        answers that and identifies the caller in one go — paho's callback
+        thread has no loop; every legitimate hard-reset caller (FastAPI
+        handlers, background async tasks) does."""
         try:
         try:
             loop = asyncio.get_running_loop()
             loop = asyncio.get_running_loop()
         except RuntimeError:
         except RuntimeError:
@@ -1636,18 +1651,15 @@ class BambuMQTTClient:
         client_id, so the broker drops the old session and paho's local
         client_id, so the broker drops the old session and paho's local
         QoS 1 queue is gone. Must NOT be called from paho's network thread.
         QoS 1 queue is gone. Must NOT be called from paho's network thread.
         Caller is responsible for setting ``_stale_reconnecting`` and
         Caller is responsible for setting ``_stale_reconnecting`` and
-        broadcasting the disconnected state."""
+        broadcasting the disconnected state.
+
+        Returns as fast as it can build a client: the old one's teardown is
+        handed off rather than waited on, because waiting on it is what
+        stopped the event loop in #3068. See ``retire_paho_client``."""
         old_client = self._client
         old_client = self._client
         self._client = None
         self._client = None
         if old_client is not None:
         if old_client is not None:
-            try:
-                old_client.disconnect()  # MQTT DISCONNECT — broker drops session
-            except Exception:
-                pass
-            try:
-                old_client.loop_stop()  # blocks briefly until the network thread exits
-            except Exception:
-                pass
+            retire_paho_client(old_client, self.serial_number)
         # Skip reconnect if no asyncio loop is available (test environment or
         # Skip reconnect if no asyncio loop is available (test environment or
         # pre-init). The next initial connect() call from PrinterManager will
         # pre-init). The next initial connect() call from PrinterManager will
         # set up the client fresh.
         # set up the client fresh.
@@ -2200,7 +2212,15 @@ class BambuMQTTClient:
             # next 1-2 push_status frames may still carry the printer's OLD cfg
             # next 1-2 push_status frames may still carry the printer's OLD cfg
             # for ~3 s before the firmware reflects the change. Without this
             # for ~3 s before the firmware reflects the change. Without this
             # gate the UI would flicker ON→OFF→ON. Same pattern xcam uses.
             # gate the UI would flicker ON→OFF→ON. Same pattern xcam uses.
-            new_backup = parse_ams_filament_backup_from_cfg(print_data.get("cfg"))
+            # Only from a status frame: a project_file ack echoes our own
+            # `"cfg": "0"` back, which read as "printer says backup is OFF" and
+            # stuck on every family that doesn't repeat `cfg` in its periodic
+            # frames — P1S, A1, A1 Mini, A2L (#3040).
+            new_backup = (
+                parse_ams_filament_backup_from_cfg(print_data.get("cfg"))
+                if is_printer_status_frame(print_data)
+                else None
+            )
             if new_backup is not None and new_backup != self.state.ams_filament_backup:
             if new_backup is not None and new_backup != self.state.ams_filament_backup:
                 hold_start = self._xcam_hold_start.get("print_option_auto_switch_filament")
                 hold_start = self._xcam_hold_start.get("print_option_auto_switch_filament")
                 if hold_start is not None and (time.time() - hold_start) <= self._xcam_hold_time:
                 if hold_start is not None and (time.time() - hold_start) <= self._xcam_hold_time:
@@ -3677,7 +3697,7 @@ class BambuMQTTClient:
         cycle ending at 63 degC with the reading still above the threshold is
         cycle ending at 63 degC with the reading still above the threshold is
         the whole shape of the re-arm loop.
         the whole shape of the re-arm loop.
         """
         """
-        box = f"temp={ams_unit.get('temp')} humidity={ams_unit.get('humidity_raw', ams_unit.get('humidity'))}"
+        box = f"temp={ams_unit.get('temp')} humidity={ams_humidity_percent(ams_unit)}"
         if ams_id in self._drying_stops_sent:
         if ams_id in self._drying_stops_sent:
             self._drying_stops_sent.discard(ams_id)
             self._drying_stops_sent.discard(ams_id)
             logger.info(
             logger.info(
@@ -4947,8 +4967,10 @@ class BambuMQTTClient:
             except (ValueError, TypeError):
             except (ValueError, TypeError):
                 logger.debug("[%s] could not parse stat field: %r", self.serial_number, data["stat"])
                 logger.debug("[%s] could not parse stat field: %r", self.serial_number, data["stat"])
 
 
-        # Parse timelapse status (recording active during print)
-        if "timelapse" in data:
+        # Parse timelapse status (recording active during print). Status frames
+        # only — the project_file ack echoes back the per-job timelapse flag we
+        # asked for, which is a request, not the recorder's state (#3040).
+        if "timelapse" in data and is_printer_status_frame(data):
             logger.debug("[%s] timelapse field: %s", self.serial_number, data["timelapse"])
             logger.debug("[%s] timelapse field: %s", self.serial_number, data["timelapse"])
             self.state.timelapse = data["timelapse"] is True
             self.state.timelapse = data["timelapse"] is True
             # Track if timelapse was ever active during this print
             # Track if timelapse was ever active during this print
@@ -6001,7 +6023,11 @@ class BambuMQTTClient:
                     "vibration_cali": vibration_cali,
                     "vibration_cali": vibration_cali,
                     "layer_inspect": layer_inspect,
                     "layer_inspect": layer_inspect,
                     "use_ams": use_ams,
                     "use_ams": use_ams,
-                    "cfg": "0",
+                    # No "cfg": it is the printer's device-config bitmask
+                    # (auto-refill, detect-on-insert, chamber light, ...), not a
+                    # per-job field — BambuStudio's PrintParams has no such
+                    # member. We used to send "0"; firmware ignores it, but it
+                    # comes straight back in the project_file ack (#3040).
                     # extrude_cali_flag gates flow-dynamics calibration:
                     # extrude_cali_flag gates flow-dynamics calibration:
                     # 0 = never, 1 = force every print, 2 = auto (run only if the
                     # 0 = never, 1 = force every print, 2 = auto (run only if the
                     # filament wasn't calibrated recently). #1721 saw stage 8
                     # filament wasn't calibrated recently). #1721 saw stage 8
@@ -6356,14 +6382,32 @@ class BambuMQTTClient:
         return True
         return True
 
 
     def disconnect(self, timeout: float = 0):
     def disconnect(self, timeout: float = 0):
-        """Disconnect from the printer."""
+        """Disconnect from the printer.
+
+        Waits up to *timeout* for paho to report the disconnect, then lets the
+        client go without joining its network thread — the callers are route
+        handlers (printer edited, deleted, disconnected by hand) running on the
+        asyncio thread, and that join has no bound (#3068)."""
         if self._client:
         if self._client:
+            old_client = self._client
             self._disconnection_event = threading.Event()
             self._disconnection_event = threading.Event()
-            self._client.disconnect()
+            old_client.disconnect()
+            # The callback that sets this fires on paho's thread, so it has to
+            # be given its window before retire_paho_client detaches it.
             self._disconnection_event.wait(timeout=timeout)
             self._disconnection_event.wait(timeout=timeout)
-            self._client.loop_stop()
             self._client = None
             self._client = None
+            retire_paho_client(old_client, self.serial_number)
             self.state.connected = False
             self.state.connected = False
+            # Deliberately no on_state_change here. paho's disconnect callback
+            # used to land during the join, but `_on_disconnect` suppresses
+            # itself for a clean disconnect of a printer that reported within
+            # the last 10s -- which is every healthy printer -- so a
+            # hand-disconnected printer never broadcast one. Announcing it now
+            # would fire the connected→disconnected edge in
+            # `on_printer_status_change` and notify the user their printer went
+            # offline a minute after they disconnected it on purpose (#1752).
+            # The callers drop the client from the manager anyway, so the next
+            # status read already shows it gone.
 
 
     def send_command(self, command: dict):
     def send_command(self, command: dict):
         """Send a command to the printer."""
         """Send a command to the printer."""

+ 1 - 1
backend/app/services/diagnostic_snapshot.py

@@ -178,7 +178,7 @@ def _mask_string(value: str, sensitive_strings: dict[str, str]) -> str:
     Known values are matched first (longest first so "My Printer 1" beats
     Known values are matched first (longest first so "My Printer 1" beats
     "My Printer"); the regex pass then catches any IPs the sensitive_strings
     "My Printer"); the regex pass then catches any IPs the sensitive_strings
     table didn't already cover — most importantly the Bambuddy host's own
     table didn't already cover — most importantly the Bambuddy host's own
-    IP (returned by ``_get_host_ip`` inside the diagnostic, not in the DB)
+    IP (returned by ``_host_source_ip`` inside the diagnostic, not in the DB)
     and any virtual-printer ``bind_ip`` the user picked at setup.
     and any virtual-printer ``bind_ip`` the user picked at setup.
     """
     """
     if not value:
     if not value:

+ 99 - 1
backend/app/services/discovery.py

@@ -23,8 +23,106 @@ from pathlib import Path
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
+# Runtime names :func:`detect_container_runtime` can return. These reach the
+# user in the connection diagnostic, so they are the names people know their
+# own setup by.
+RUNTIME_DOCKER = "Docker"
+RUNTIME_PODMAN = "Podman"
+RUNTIME_KUBERNETES = "Kubernetes"
+RUNTIME_CONTAINERD = "containerd"
+RUNTIME_LXC = "LXC"
+# Sentinel for "in a container we cannot name". The others are proper nouns
+# that interpolate into any language; this one is localized by the frontend
+# (diagnostic.check.network_mode.genericRuntime), so keep the two in step.
+RUNTIME_OTHER = "container"
+
+# Runtimes that put Bambuddy in an OCI container whose network mode is a
+# choice the user made and can change. LXC is deliberately absent: a Proxmox
+# or LXD system container is bridged onto the LAN like a small VM, so there is
+# no "recreate it with host networking" advice to give.
+OCI_RUNTIMES = frozenset({RUNTIME_DOCKER, RUNTIME_PODMAN, RUNTIME_KUBERNETES, RUNTIME_CONTAINERD, RUNTIME_OTHER})
+
+# systemd writes the engine's own name here. It is the only signal that tells
+# Podman apart from Docker without guessing, which is why it is consulted
+# first (see updates.py, which has used it for the same reason for longer).
+_SYSTEMD_CONTAINER = Path("/run/systemd/container")
+
+_SYSTEMD_RUNTIME_NAMES = {
+    "docker": RUNTIME_DOCKER,
+    "podman": RUNTIME_PODMAN,
+    "containerd": RUNTIME_CONTAINERD,
+    "lxc": RUNTIME_LXC,
+    "lxc-libvirt": RUNTIME_LXC,
+    "oci": RUNTIME_OTHER,
+}
+
+
+def _read_text(path: Path) -> str:
+    """Read a small /proc or /run marker file, empty string if unreadable."""
+    try:
+        return path.read_text()
+    except (OSError, ValueError):
+        # Unreadable, absent, or /proc entry that vanished mid-read.
+        return ""
+
+
+def detect_container_runtime() -> str | None:
+    """Name the container runtime Bambuddy is running under, or None.
+
+    ``is_running_in_docker`` below answers a narrower question and is
+    deliberately left alone — see the comment on it.
+
+    Detection is ordered most-specific first, because the generic markers
+    cannot tell two engines apart: Podman sets ``/run/.containerenv`` *and*
+    writes ``libpod`` into the cgroup path, while Docker sets ``/.dockerenv``
+    and writes ``docker``. A container started by neither still gets a name
+    (``container``) rather than None, because "we are in something" is a
+    useful answer even when the engine is not.
+    """
+    systemd_name = _read_text(_SYSTEMD_CONTAINER).strip().lower()
+    if systemd_name:
+        return _SYSTEMD_RUNTIME_NAMES.get(systemd_name, RUNTIME_OTHER)
+
+    # Podman writes /run/.containerenv into every container it starts. Older
+    # versions put it at the root, so both are checked.
+    if Path("/run/.containerenv").exists() or Path("/.containerenv").exists():
+        return RUNTIME_PODMAN
+    if Path("/.dockerenv").exists():
+        return RUNTIME_DOCKER
+
+    cgroup = _read_text(Path("/proc/1/cgroup"))
+    if "libpod" in cgroup:
+        return RUNTIME_PODMAN
+    if "kubepods" in cgroup:
+        return RUNTIME_KUBERNETES
+    if "docker" in cgroup:
+        return RUNTIME_DOCKER
+    if "containerd" in cgroup:
+        return RUNTIME_CONTAINERD
+    if "/lxc" in cgroup:
+        return RUNTIME_LXC
+
+    env_name = (os.environ.get("CONTAINER") or "").strip().lower()
+    if env_name:
+        return _SYSTEMD_RUNTIME_NAMES.get(env_name, RUNTIME_OTHER)
+    if os.environ.get("DOCKER_CONTAINER"):
+        return RUNTIME_DOCKER
+
+    return None
+
+
 def is_running_in_docker() -> bool:
 def is_running_in_docker() -> bool:
-    """Detect if we're running inside a Docker container."""
+    """Detect if we're running inside a Docker container.
+
+    Kept Docker-specific on purpose, and NOT rewritten on top of
+    :func:`detect_container_runtime`. Three callers key real behaviour off
+    this: ``/api/discovery/info`` feeds it to the Add-Printer flow, where
+    ``isDocker`` switches discovery from SSDP to subnet scanning, and the
+    backup-path probe and support bundle both read it. Answering True for a
+    host-networked Podman container would take SSDP away from users for whom
+    it works (#3092). Widening it is a separate decision from naming the
+    runtime, so it is made separately.
+    """
     # Check for .dockerenv file
     # Check for .dockerenv file
     if Path("/.dockerenv").exists():
     if Path("/.dockerenv").exists():
         return True
         return True

+ 12 - 4
backend/app/services/external_camera.py

@@ -1112,10 +1112,18 @@ async def _stream_rtsp(
         "1024000",
         "1024000",
         "-max_delay",
         "-max_delay",
         "500000",
         "500000",
-        "-probesize",
-        "32",
-        "-analyzeduration",
-        "0",
+        # No probe cap here (#3082). The input is whatever camera the user
+        # owns, so there is no stream to tune a fast-start probe against: a
+        # 32-byte probe expires before a source that carries SPS/PPS in-band
+        # rather than in its SDP has sent them, and ffmpeg then starts no
+        # H.264 decoder and emits nothing at all. ffmpeg's defaults are a
+        # ceiling rather than a wait, so a camera that announces itself in the
+        # first packet still starts as fast as it ever did.
+        #
+        # `_capture_rtsp_frame` has always run on those defaults, which is how
+        # a camera could pass the connection test and still show a black live
+        # view. The printer path is the opposite case — a known Bambu camera
+        # per model — and keeps its tuning in `camera_profiles.py`.
         "-fflags",
         "-fflags",
         "nobuffer",
         "nobuffer",
         "-flags",
         "-flags",

+ 18 - 0
backend/app/services/finance_balance.py

@@ -4,6 +4,24 @@ from sqlalchemy import and_, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
 from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
+from backend.app.models.settings import Settings as AppSettingModel
+from backend.app.schemas.settings import AppSettings as AppSettingsSchema
+
+
+async def resolve_configured_currency(db: AsyncSession) -> str:
+    """The currency this install reports balances in.
+
+    Every other surface in Bambuddy renders the ``currency`` app setting.
+    Finance used to answer from ``user_wallets.currency``, which three of its
+    four writers filled with a hardcoded "EUR", so an install configured for
+    AUD reported a euro balance (#3123). That column is gone; this is the one
+    place that answers the question.
+    """
+    result = await db.execute(select(AppSettingModel).where(AppSettingModel.key == "currency"))
+    setting = result.scalar_one_or_none()
+    if setting and setting.value:
+        return setting.value
+    return AppSettingsSchema().currency
 
 
 
 
 def transaction_affects_personal_balance(
 def transaction_affects_personal_balance(

+ 1 - 1
backend/app/services/finance_billing.py

@@ -220,7 +220,7 @@ async def apply_print_charge_for_archive(
 
 
         wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == actual_user_id))).scalar_one_or_none()
         wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == actual_user_id))).scalar_one_or_none()
         if wallet is None:
         if wallet is None:
-            wallet = UserWallet(user_id=actual_user_id, balance=0.0, currency="EUR")
+            wallet = UserWallet(user_id=actual_user_id, balance=0.0)
             db.add(wallet)
             db.add(wallet)
             await db.flush()
             await db.flush()
             logger.info("Created new wallet for user ID %s.", actual_user_id)
             logger.info("Created new wallet for user ID %s.", actual_user_id)

+ 1 - 8
backend/app/services/finance_defaults.py

@@ -2,9 +2,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet
 from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet
-from backend.app.models.settings import Settings as AppSettingModel
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.schemas.settings import AppSettings as AppSettingsSchema
 
 
 
 
 async def ensure_user_finance_defaults(db: AsyncSession, user: User) -> bool:
 async def ensure_user_finance_defaults(db: AsyncSession, user: User) -> bool:
@@ -16,12 +14,7 @@ async def ensure_user_finance_defaults(db: AsyncSession, user: User) -> bool:
 
 
     wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user.id))).scalar_one_or_none()
     wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user.id))).scalar_one_or_none()
     if wallet is None:
     if wallet is None:
-        # Respect admin-configured currency if present, otherwise fall back to app default
-        default_currency = AppSettingsSchema().currency
-        result = await db.execute(select(AppSettingModel).where(AppSettingModel.key == "currency"))
-        setting = result.scalar_one_or_none()
-        currency = setting.value if setting and setting.value else default_currency
-        db.add(UserWallet(user_id=user.id, balance=0.0, currency=currency))
+        db.add(UserWallet(user_id=user.id, balance=0.0))
         changed = True
         changed = True
 
 
     private_center = (
     private_center = (

+ 1 - 1
backend/app/services/github_backup.py

@@ -535,8 +535,8 @@ class GitHubBackupService:
         Both stores are read regardless: a ``Settings`` row survives enabling
         Both stores are read regardless: a ``Settings`` row survives enabling
         auth later, and dropping it silently would lose that account's presets.
         auth later, and dropping it silently would lose that account's presets.
         """
         """
-        from backend.app.api.routes.cloud import get_stored_token
         from backend.app.api.routes.orca_cloud import _load_credentials
         from backend.app.api.routes.orca_cloud import _load_credentials
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         bambu: list = []
         bambu: list = []
         orca: list = []
         orca: list = []

+ 51 - 0
backend/app/services/model_providers/__init__.py

@@ -0,0 +1,51 @@
+"""Model-provider interface + registry.
+
+The shared seam for "import a model from a 3D model website". Providers
+implement the interface (a ``ModelProvider`` descriptor + a per-request
+``ProviderService`` transport) and register an instance here; the registry
+routes pasted URLs to the owning provider via ``find_for_url``. MakerWorld is
+the first (and currently only) registered provider.
+"""
+
+from backend.app.services.model_providers.base import (
+    ModelProvider,
+    ProviderAuthConfig,
+    ProviderAuthError,
+    ProviderAuthType,
+    ProviderDownload,
+    ProviderDownloadInfo,
+    ProviderError,
+    ProviderForbiddenError,
+    ProviderNotFoundError,
+    ProviderResolvedModel,
+    ProviderResourceRef,
+    ProviderService,
+    ProviderStatus,
+    ProviderUnavailableError,
+    ProviderUrlError,
+)
+from backend.app.services.model_providers.makerworld import makerworld_provider
+from backend.app.services.model_providers.registry import ModelProviderRegistry, registry
+
+registry.register(makerworld_provider)
+
+__all__ = [
+    "ModelProvider",
+    "ModelProviderRegistry",
+    "ProviderAuthConfig",
+    "ProviderAuthError",
+    "ProviderAuthType",
+    "ProviderDownload",
+    "ProviderDownloadInfo",
+    "ProviderError",
+    "ProviderForbiddenError",
+    "ProviderNotFoundError",
+    "ProviderResolvedModel",
+    "ProviderResourceRef",
+    "ProviderService",
+    "ProviderStatus",
+    "ProviderUnavailableError",
+    "ProviderUrlError",
+    "makerworld_provider",
+    "registry",
+]

+ 327 - 0
backend/app/services/model_providers/base.py

@@ -0,0 +1,327 @@
+"""Model-provider interface.
+
+A *model provider* is a website that hosts 3D printer models (MakerWorld,
+Thingiverse, Printables, ...) whose files Bambuddy can resolve and import
+into the library. This module defines the contract every provider must
+fulfil — the split being:
+
+  * :class:`ModelProvider` — the static, provider-wide descriptor: identity
+    (``source_type``, ``display_name``), URL routing (``host_patterns``),
+    the auth it needs (or explicitly doesn't), and a factory that builds a
+    per-request :class:`ProviderService` seeded with the caller's stored
+    credentials.
+  * :class:`ProviderService` — one HTTP client per request, mirroring the
+    ``BambuCloudService`` construction pattern: resolve a model URL to
+    metadata + importable files, resolve + fetch a concrete download, and
+    proxy thumbnail images. Providers are *thin transports*: shared concerns
+    (library dedupe, folder auto-creation, ``save_3mf_bytes_to_library``)
+    stay in the route layer so every provider benefits from them.
+
+The interface deliberately covers everything the MakerWorld integration
+needs today (see ``model_providers/makerworld/``) so that adding a new site
+is: implement ``ModelProvider`` + ``ProviderService``, register it, and the
+shared import API routes pasted URLs to it via ``registry.find_for_url``.
+
+Only interoperability — not affiliated with or endorsed by MakerWorld or any
+other provider, and not intended to circumvent any access control.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+from urllib.parse import urlparse
+
+import httpx
+
+from backend.app.core.compat import StrEnum
+
+if TYPE_CHECKING:
+    from sqlalchemy.ext.asyncio import AsyncSession
+
+    from backend.app.core.permissions import Permission
+    from backend.app.models.user import User
+
+
+class ProviderAuthType(StrEnum):
+    """The kind of credentials a model provider may (optionally) require."""
+
+    NONE = "none"
+    ACCESS_TOKEN = "access_token"
+    USERNAME_PASSWORD = "username_password"
+    BAMBU_CLOUD_BEARER = "bambu_cloud_bearer"  # MakerWorld today: shared Bambu Cloud token
+    COOKIE = "cookie"  # reserved for sites without a first-party API
+
+
+@dataclass(frozen=True)
+class ProviderAuthConfig:
+    """Declarative description of a provider's authentication requirement.
+
+    Describes *what* the provider needs so the UI can prompt for it; the
+    actual storage/retrieval of credentials stays provider-specific for now
+    (MakerWorld reads the Bambu Cloud token the user already configured).
+    ``credential_fields`` names the inputs a future generic credential vault
+    would collect (e.g. ``("access_token",)`` or ``("username", "password")``).
+    """
+
+    auth_type: ProviderAuthType
+    display_label: str
+    description: str = ""
+    credential_fields: tuple[str, ...] = ()
+    setup_hint: str = ""
+
+
+@dataclass(frozen=True)
+class ProviderResourceRef:
+    """Provider-agnostic handle for one model resource.
+
+    ``external_id`` is the provider-native model identifier (MakerWorld's
+    integer design id as a string); ``sub_id`` is an optional secondary key
+    such as MakerWorld's ``profileId`` for a specific plate.
+
+    Both ids must be **numeric strings** today: the shared route layer casts
+    them with ``int()`` when shaping API responses. Providers whose native
+    ids are not numeric need route-layer changes first — keep this contract
+    in mind when implementing one.
+    """
+
+    source_type: str
+    external_id: str
+    sub_id: str | None = None
+    original_url: str | None = None
+
+
+@dataclass
+class ProviderStatus:
+    """Whether the caller can use this provider right now.
+
+    ``auth_error`` carries a human-readable reason when the caller is signed
+    in but the stored credential has been rejected (e.g. expired); ``None``
+    when there is no error to report. ``credential_rejected`` is the
+    machine-readable counterpart — set exactly when the stored credential
+    exists *and* was refused by the provider — so callers (e.g. a route
+    reporting "sign-in expired") never have to infer it from ``auth_error``,
+    which may legitimately be set for other failures (network, rate limit).
+    """
+
+    authenticated: bool
+    can_download: bool
+    auth_error: str | None = None
+    credential_rejected: bool = False
+
+
+@dataclass
+class ProviderResolvedModel:
+    """Result of resolving a model URL.
+
+    ``design`` and ``instances`` are provider-specific dicts passed through
+    verbatim — the frontend reads fields a provider may add over time, so we
+    don't re-shape them here. Which library rows already hold this resource
+    is the route layer's concern (it owns the library query) and stays out of
+    the resolved payload.
+    """
+
+    ref: ProviderResourceRef
+    design: dict[str, Any]
+    instances: list[dict[str, Any]] = field(default_factory=list)
+
+
+@dataclass(frozen=True)
+class ProviderDownloadInfo:
+    """A concrete, short-lived download for one file/plate.
+
+    ``ref`` may be enriched by the provider with the ``sub_id`` it resolved
+    (e.g. the actual MakerWorld profile selected when the caller omitted
+    one) so the route can build the canonical dedupe URL.
+    """
+
+    ref: ProviderResourceRef
+    url: str
+    suggested_filename: str
+
+
+@dataclass
+class ProviderDownload:
+    """Downloaded file bytes plus the final suggested filename."""
+
+    file_bytes: bytes
+    filename: str
+
+
+class ProviderError(Exception):
+    """Base exception for model-provider API errors."""
+
+
+class ProviderAuthError(ProviderError):
+    """Raised when a provider requires credentials and we have none (or the
+    stored one was rejected). True auth failure."""
+
+
+class ProviderForbiddenError(ProviderError):
+    """Raised when a provider refuses access despite valid authentication —
+    content-gated (purchase/points required, region restricted, ...)."""
+
+
+class ProviderNotFoundError(ProviderError):
+    """Raised when a model / file / profile doesn't exist."""
+
+
+class ProviderUnavailableError(ProviderError):
+    """Raised on 5xx, network errors, or malformed payloads."""
+
+
+class ProviderUrlError(ProviderError):
+    """Raised when a URL isn't a model page of this provider."""
+
+
+class ModelProvider(ABC):
+    """Static descriptor + factory for one model-hosting site.
+
+    Instances are shared (one per provider); all mutable state lives in the
+    per-request :class:`ProviderService` built by :meth:`build_service`.
+    """
+
+    source_type: str
+    display_name: str
+    host_patterns: tuple[str, ...] = ()
+    auth: ProviderAuthConfig | None = None
+    #: Top-level library folder imports land in when the caller names no
+    #: folder. ``None`` imports into the library root — the route will not
+    #: mint a folder without a name.
+    default_folder_name: str | None = None
+    #: The permissions the routes enforce for this provider's read and import
+    #: operations. Optional only so the base class has a default: a provider
+    #: that leaves them unset is refused at the gate rather than treated as
+    #: unrestricted (see ``makerworld._authorize_for_provider``).
+    view_permission: Permission | None = None
+    import_permission: Permission | None = None
+
+    @abstractmethod
+    async def build_service(
+        self,
+        *,
+        db: AsyncSession,
+        user: User | None,
+        api_key_owner: User | None = None,
+        client: httpx.AsyncClient | None = None,
+    ) -> ProviderService:
+        """Build a per-request service seeded with the caller's credentials.
+
+        ``api_key_owner`` is the API key's owning user for API-keyed calls
+        (see ``resolve_api_key_cloud_owner``); providers use it as the
+        fallback identity when ``user`` is None.
+        """
+
+    @abstractmethod
+    def parse_url(self, url: str) -> ProviderResourceRef:
+        """Extract a :class:`ProviderResourceRef` from a model URL.
+
+        Raises :class:`ProviderUrlError` when the URL isn't a model page of
+        this provider.
+        """
+
+    @abstractmethod
+    def canonical_url(self, ref: ProviderResourceRef) -> str:
+        """Stable dedupe key for a resource (library ``source_url``).
+
+        All URL variants of the same resource must collapse to this string;
+        different resources (e.g. different plates of one model) must differ.
+        """
+
+    def source_url_filter(self, column: Any, external_id: str) -> Any:
+        """SQL predicate over ``LibraryFile.source_url`` selecting every row
+        that belongs to this resource — the whole-model canonical URL plus,
+        when the provider keys dedupe per sub-resource (plate/profile), every
+        such variant. Drives the resolve flow's already-imported detection.
+
+        The default matches the model-level canonical URL only; providers with
+        recognisable per-plate URL shapes override this (see MakerWorld).
+        """
+        prefix = self.canonical_url(ProviderResourceRef(source_type=self.source_type, external_id=external_id))
+        return column == prefix
+
+    def supports_url(self, url: str) -> bool:
+        """Whether ``url`` points at this provider (host-suffix match).
+
+        Accepts scheme-less input (``makerworld.com/models/1``) the same way
+        :meth:`parse_url` does, so ``find_for_url`` routes exactly the URLs
+        the provider will then accept.
+        """
+        if not url or not isinstance(url, str):
+            return False
+        candidate = url.strip()
+        if "://" not in candidate:
+            candidate = "https://" + candidate
+        try:
+            host = (urlparse(candidate).hostname or "").lower()
+        except ValueError:
+            return False
+        return any(host == pattern or host.endswith("." + pattern) for pattern in self.host_patterns)
+
+    def thumbnail_hosts(self) -> tuple[str, ...]:
+        """Hosts whose image URLs may be proxied by ``fetch_thumbnail``.
+
+        Serves as the SSRF allowlist for the provider's image proxy; empty
+        means the provider has no server-side thumbnail proxy.
+        """
+        return ()
+
+    def download_hosts(self) -> tuple[str, ...]:
+        """Hosts whose file URLs may be fetched by the download path.
+
+        Serves as the SSRF allowlist for :meth:`ProviderService.download`,
+        symmetric to :meth:`thumbnail_hosts`; empty means the provider has no
+        server-side file fetch (so no allowlist constraint applies). Providers
+        whose service fetches files must override this — a new provider gets
+        the same structural hint the thumbnail proxy gives its counterpart.
+        """
+        return ()
+
+
+class ProviderService(ABC):
+    """Per-request client for a single provider.
+
+    Built by :meth:`ModelProvider.build_service`, never constructed directly.
+    Providers must be closed after use (:meth:`close`); the shared connection
+    pool is only closed by the owner.
+    """
+
+    @abstractmethod
+    async def close(self) -> None:
+        """Close the client if this service instance owns it."""
+
+    @abstractmethod
+    async def get_status(self, db: AsyncSession) -> ProviderStatus:
+        """Report whether the caller can use this provider (credential state)."""
+
+    @abstractmethod
+    async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
+        """Fetch metadata + the importable file/plate list for a resource."""
+
+    @abstractmethod
+    async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
+        """Resolve the concrete download for a resource/file.
+
+        May need provider-specific lookups (e.g. MakerWorld's alphanumeric
+        ``modelId``) and must enrich ``ref.sub_id`` with the actually-resolved
+        file/plate so the route can build the canonical dedupe key.
+        Raises ``ProviderAuthError`` when the provider requires credentials
+        and the caller has none.
+        """
+
+    @abstractmethod
+    async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
+        """Fetch the file bytes for a :class:`ProviderDownloadInfo`.
+
+        Must restrict the upstream URL host to :meth:`ModelProvider.download_hosts`
+        (SSRF guard — the symmetric counterpart to ``fetch_thumbnail``).
+        """
+
+    @abstractmethod
+    async def fetch_thumbnail(self, url: str) -> tuple[bytes, str]:
+        """Proxy a provider CDN image, returning ``(bytes, content_type)``.
+
+        Must restrict the upstream host to :meth:`ModelProvider.thumbnail_hosts`
+        (SSRF guard).
+        """

+ 12 - 0
backend/app/services/model_providers/makerworld/__init__.py

@@ -0,0 +1,12 @@
+"""MakerWorld model provider package.
+
+Exports the provider instance the registry consumes; the implementation lives
+in the sibling modules (``service``, ``http``, ``url``, ``errors``, ``auth``).
+"""
+
+from backend.app.services.model_providers.makerworld.provider import (
+    MakerWorldProvider,
+    makerworld_provider,
+)
+
+__all__ = ["MakerWorldProvider", "makerworld_provider"]

+ 18 - 0
backend/app/services/model_providers/makerworld/auth.py

@@ -0,0 +1,18 @@
+"""MakerWorld credential handling.
+
+MakerWorld downloads run on the same Bambu Cloud bearer token as the rest of
+the Bambu cloud integration — there is no separate MakerWorld OAuth flow. This
+module is the single seam where the MakerWorld provider reads the caller's
+stored token, reports a rejected/expired credential, and records a 401 so the
+whole app agrees the sign-in is dead (see ``cloud.mark_cloud_token_invalid``).
+"""
+
+from __future__ import annotations
+
+from backend.app.services.bambu_cloud_credentials import (
+    get_stored_token,
+    is_cloud_token_invalid,
+    mark_cloud_token_invalid,
+)
+
+__all__ = ["get_stored_token", "is_cloud_token_invalid", "mark_cloud_token_invalid"]

+ 44 - 0
backend/app/services/model_providers/makerworld/errors.py

@@ -0,0 +1,44 @@
+"""MakerWorld error types.
+
+Subclasses of the generic provider hierarchy so route layers can map errors
+with the provider-agnostic classes (:class:`ProviderError` and friends) while
+callers that know they're talking to MakerWorld get the specific types.
+"""
+
+from __future__ import annotations
+
+from backend.app.services.model_providers.base import (
+    ProviderAuthError,
+    ProviderError,
+    ProviderForbiddenError,
+    ProviderNotFoundError,
+    ProviderUnavailableError,
+    ProviderUrlError,
+)
+
+
+class MakerWorldError(ProviderError):
+    """Base exception for MakerWorld API errors."""
+
+
+class MakerWorldAuthError(ProviderAuthError, MakerWorldError):
+    """Raised when MakerWorld requires a Bambu Cloud token and we don't have
+    one (or the one we sent was rejected). True auth failure."""
+
+
+class MakerWorldForbiddenError(ProviderForbiddenError, MakerWorldError):
+    """Raised when MakerWorld refuses access despite valid authentication —
+    content-gated (points required, purchase required, region restricted,
+    early-access, etc.)."""
+
+
+class MakerWorldNotFoundError(ProviderNotFoundError, MakerWorldError):
+    """Raised when a design / profile / instance doesn't exist."""
+
+
+class MakerWorldUnavailableError(ProviderUnavailableError, MakerWorldError):
+    """Raised on 5xx, network errors, or malformed payloads."""
+
+
+class MakerWorldUrlError(ProviderUrlError, MakerWorldError):
+    """Raised when a URL isn't a makerworld.com model page."""

+ 166 - 0
backend/app/services/model_providers/makerworld/http.py

@@ -0,0 +1,166 @@
+"""MakerWorld HTTP layer.
+
+Constants and the low-level transport helpers for the MakerWorld / Bambu Lab
+APIs: the S3 presigned-download path that must reach the transport
+byte-for-byte, upstream error extraction, and the CDN SSRF guard helpers used
+by :class:`MakerWorldService`.
+
+The app-scoped shared ``httpx`` client lives with its consumer instead (see
+``service.set_shared_http_client``) — same-module so the service reads the
+live value, matching ``bambu_cloud`` / ``orca_cloud`` / ``slicer_api``.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import ssl
+
+import certifi
+import httpx
+
+from backend.app.services.model_providers.makerworld.errors import MakerWorldUnavailableError
+
+# API base: ``api.bambulab.com/v1/design-service`` — the same Bambu Cloud
+# backend that the MakerWorld web UI talks to, but not behind Cloudflare
+# (the website ``makerworld.com`` is, and plain httpx requests there get
+# fingerprinted as bot traffic and served "Please log in").
+MAKERWORLD_API_BASE = "https://api.bambulab.com/v1/design-service"
+
+# Besides MakerWorld's own CDN, Bambu Cloud also issues AWS S3 presigned
+# URLs (e.g. ``s3.us-west-2.amazonaws.com``) from the iot-service download
+# endpoint. The suffix check matches any regional S3 endpoint.
+#
+# Deliberately NOT part of the ``download_hosts()`` seam: that seam is an
+# exact-host allowlist a provider declares, and this is a suffix family
+# belonging to Bambu's signed-URL infrastructure specifically. It stays a
+# constant of *this* provider's transport: ``download_3mf`` accepts the
+# injected hosts or an S3 endpoint, while a second provider brings its own
+# service and declares its own ``download_hosts()``.
+_ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",)
+
+# The shared default SSRF allowlist for MakerWorld CDN traffic. The thumbnail
+# proxy and the 3MF download path are both driven by the provider descriptor
+# instead — ``build_service`` feeds the runner's ``ModelProvider.thumbnail_hosts()``
+# and ``download_hosts()`` into ``MakerWorldService`` — and this tuple is what
+# those methods return by default. Lives here with the other transport guards
+# so the allowlist is in one place.
+MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com")
+
+# Client identity sent to MakerWorld / api.bambulab.com. We identify honestly
+# as Bambuddy with a source URL so Bambu can distinguish our traffic from
+# impersonators — the opposite of what the OrcaSlicer fork was called out for
+# in the May 2026 Bambu Lab blog post on cloud access. The Referer is kept
+# because MakerWorld's CSRF / origin-check middleware uses it on some
+# endpoints — that's distinct from client impersonation.
+_CLIENT_HEADERS = {
+    "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
+    "Accept": "text/html,application/json,*/*",
+    "Accept-Language": "en-US,en;q=0.9",
+    "Referer": "https://makerworld.com/",
+}
+
+_MAX_3MF_BYTES = 200 * 1024 * 1024  # 200 MB hard cap
+_MAX_THUMBNAIL_BYTES = 10 * 1024 * 1024  # 10 MB hard cap — MakerWorld's "thumbnails" can be 2–3 MB source images
+
+_IMAGE_EXT_TO_MIME = {
+    ".png": "image/png",
+    ".jpg": "image/jpeg",
+    ".jpeg": "image/jpeg",
+    ".gif": "image/gif",
+    ".webp": "image/webp",
+    ".bmp": "image/bmp",
+}
+# Content types we refuse even if the URL extension looks image-y — prevents
+# forwarding an upstream error page or JSON blob with image framing.
+_REFUSED_THUMBNAIL_MIMES = ("text/html", "text/plain", "application/json")
+
+
+def _s3_ssl_context() -> ssl.SSLContext:
+    """Build the TLS context used for the S3 presigned download (#2562).
+
+    ``urllib.request`` verifies against the *OS* trust store, while httpx —
+    every other network call in Bambuddy — verifies against the bundled
+    ``certifi`` CA bundle. On Windows those two disagree: Python's
+    ``ssl.load_default_certs()`` only enumerates the roots already cached in
+    the Windows ROOT store, and Windows populates that store lazily via
+    CryptoAPI's auto-update, which Python never triggers. If the Amazon root
+    signing the S3 chain isn't cached on that machine yet, verification fails
+    with ``unable to get local issuer certificate`` — even though the
+    api.bambulab.com calls that preceded it (httpx) succeeded.
+
+    Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
+    the app already trusts. Built per call rather than at import so a certifi
+    refresh doesn't require a restart; construction is cheap relative to the
+    download that follows.
+    """
+    return ssl.create_default_context(cafile=certifi.where())
+
+
+async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes, str]:
+    """Fetch an AWS S3 presigned URL without touching the query string.
+
+    ``urllib.request`` passes the URL to the transport verbatim — which is
+    essential for S3 presigned URLs where the signature is computed over
+    the exact query-string bytes. httpx's ``URL`` class and curl_cffi's
+    libcurl layer both normalise encodings and produce
+    ``SignatureDoesNotMatch`` 400s from S3.
+
+    Runs the blocking urllib call in a thread executor so we don't stall
+    the event loop.
+    """
+    from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
+
+    # Don't follow redirects: the host allowlist is only enforced on
+    # the initial URL. A 302 from S3 to any other host would otherwise
+    # transparently bypass the allowlist — so insist S3 resolve directly.
+    class _NoRedirect(HTTPRedirectHandler):
+        def redirect_request(self, *args, **kwargs):  # type: ignore[override]
+            return None
+
+    # HTTPSHandler swaps only the TLS context — the URL still reaches the
+    # transport verbatim, which is what the S3 signature depends on.
+    opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
+
+    def _blocking_fetch() -> bytes:
+        req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
+        with opener.open(req, timeout=60.0) as resp:
+            if resp.status != 200:
+                raise MakerWorldUnavailableError(f"3MF download returned HTTP {resp.status}")
+            data = b""
+            while True:
+                chunk = resp.read(65536)
+                if not chunk:
+                    break
+                data += chunk
+                if len(data) > _MAX_3MF_BYTES:
+                    raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap")
+            return data
+
+    try:
+        data = await asyncio.to_thread(_blocking_fetch)
+    except MakerWorldUnavailableError:
+        raise
+    except Exception as exc:  # noqa: BLE001 — urllib throws a zoo of exceptions
+        raise MakerWorldUnavailableError(f"S3 download failed: {exc}") from exc
+    return data, filename_fallback
+
+
+def _extract_upstream_error(response: httpx.Response) -> str | None:
+    """Pull MakerWorld's own error text out of a 4xx/5xx response body.
+
+    MakerWorld returns ``{"code": N, "error": "text"}`` on auth/perm failures
+    and sometimes ``{"message": "..."}`` on other errors. Returns ``None`` if
+    the body isn't JSON or doesn't have a recognised error field — callers
+    should fall back to a generic message in that case.
+    """
+    try:
+        data = response.json()
+    except ValueError:
+        return None
+    if not isinstance(data, dict):
+        return None
+    for key in ("error", "message", "detail"):
+        value = data.get(key)
+        if isinstance(value, str) and value.strip():
+            return value.strip()
+    return None

+ 117 - 0
backend/app/services/model_providers/makerworld/provider.py

@@ -0,0 +1,117 @@
+"""MakerWorld model provider.
+
+Static descriptor + per-request service factory for makerworld.com. The
+``MakerWorldProvider`` instance is what gets registered in the shared
+:class:`ModelProviderRegistry`; the actual API work lives in ``service.py``
+(the per-request :class:`ProviderService`) and ``url.py`` (URL parsing and
+canonicalisation). Credential handling is centralised here so route layers
+never touch MakerWorld specifics.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import httpx
+
+from backend.app.core.permissions import Permission
+from backend.app.services.model_providers.base import (
+    ModelProvider,
+    ProviderAuthConfig,
+    ProviderAuthType,
+    ProviderResourceRef,
+    ProviderService,
+)
+from backend.app.services.model_providers.makerworld import url as mw_url
+from backend.app.services.model_providers.makerworld.auth import (
+    get_stored_token,
+    mark_cloud_token_invalid,
+)
+from backend.app.services.model_providers.makerworld.http import MAKERWORLD_CDN_HOSTS
+from backend.app.services.model_providers.makerworld.service import MakerWorldService
+
+if TYPE_CHECKING:
+    from sqlalchemy.ext.asyncio import AsyncSession
+
+    from backend.app.models.user import User
+
+
+class MakerWorldProvider(ModelProvider):
+    """MakerWorld descriptor: identity, URL routing, auth requirements, and the
+    factory that builds a per-request :class:`MakerWorldService` seeded with the
+    caller's stored Bambu Cloud bearer token.
+    """
+
+    source_type = "makerworld"
+    display_name = "MakerWorld"
+    host_patterns = ("makerworld.com",)
+    auth = ProviderAuthConfig(
+        auth_type=ProviderAuthType.BAMBU_CLOUD_BEARER,
+        display_label="Bambu Cloud sign-in",
+        description=(
+            "MakerWorld downloads reuse the Bambu Cloud account already stored in Bambuddy — "
+            "there is no separate MakerWorld sign-in."
+        ),
+        setup_hint="Open the Profiles page and sign in to Bambu Cloud.",
+    )
+    default_folder_name = "MakerWorld"
+    view_permission = Permission.MAKERWORLD_VIEW
+    import_permission = Permission.MAKERWORLD_IMPORT
+
+    async def build_service(
+        self,
+        *,
+        db: AsyncSession,
+        user: User | None,
+        api_key_owner: User | None = None,
+        client: httpx.AsyncClient | None = None,
+    ) -> ProviderService:
+        """Build a per-request service seeded with the caller's stored Bambu
+        Cloud bearer, mirroring ``cloud.build_authenticated_cloud``.
+
+        ``api_key_owner`` is the API key's owning user for API-keyed calls
+        (see ``resolve_api_key_cloud_owner``); MakerWorld uses it as the
+        fallback identity when ``user`` is None. Like the cloud integration, a
+        rejected token is recorded so the whole app agrees the sign-in is dead
+        rather than each feature failing on its own — including auth-disabled
+        single-user installs, where ``user_id=None`` records the *global*
+        flag those installs read back on the status endpoints.
+        """
+        identity = user if user is not None else api_key_owner
+        token, _email, _region = await get_stored_token(db, identity)
+        user_id = identity.id if identity is not None else None
+        return MakerWorldService(
+            client=client,
+            auth_token=token,
+            user=identity,
+            on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
+            # The SSRF allowlists are the provider's declared seams — the
+            # service must not hardcode its own copies (symmetric pair,
+            # ``fetch_thumbnail`` / ``download``).
+            thumbnail_hosts=self.thumbnail_hosts(),
+            download_hosts=self.download_hosts(),
+        )
+
+    def parse_url(self, url: str) -> ProviderResourceRef:
+        return mw_url.parse_url(url)
+
+    def canonical_url(self, ref: ProviderResourceRef) -> str:
+        return mw_url.canonical_url(ref)
+
+    def source_url_filter(self, column, external_id: str):
+        """Whole-model key plus every per-plate key — MakerWorld's canonical
+        shape appends ``#profileId-{n}`` for plate-level dedupe (see
+        ``url.canonical_url``), so the already-imported detection must match
+        both. The ``#profileId-`` fragment lives here with the descriptor
+        because it is part of this provider's URL contract."""
+        prefix = mw_url.canonical_url(ProviderResourceRef(source_type=self.source_type, external_id=external_id))
+        return (column == prefix) | (column.like(f"{prefix}#profileId-%"))
+
+    def thumbnail_hosts(self) -> tuple[str, ...]:
+        return MAKERWORLD_CDN_HOSTS
+
+    def download_hosts(self) -> tuple[str, ...]:
+        return MAKERWORLD_CDN_HOSTS
+
+
+makerworld_provider = MakerWorldProvider()

+ 188 - 231
backend/app/services/makerworld.py → backend/app/services/model_providers/makerworld/service.py

@@ -9,7 +9,11 @@ The endpoints and header set were reverse-engineered from the
 `kloshi-io/makerworld-api-reverse` TypeScript project (Apache-2.0) and
 `kloshi-io/makerworld-api-reverse` TypeScript project (Apache-2.0) and
 cross-validated against live MakerWorld traffic. Authenticated calls reuse
 cross-validated against live MakerWorld traffic. Authenticated calls reuse
 Bambuddy's existing Bambu Cloud bearer token (same SSO backend — no separate
 Bambuddy's existing Bambu Cloud bearer token (same SSO backend — no separate
-OAuth flow needed).
+OAuth flow needed; see ``model_providers/makerworld/auth.py``).
+
+Implements the :class:`ProviderService` interface — the route layer drives it
+through ``resolve`` / ``get_download`` / ``download`` so the same flow can be
+reused for future providers.
 
 
 Only interoperability — not affiliated with or endorsed by MakerWorld or
 Only interoperability — not affiliated with or endorsed by MakerWorld or
 Bambu Lab, and not intended to circumvent any access control.
 Bambu Lab, and not intended to circumvent any access control.
@@ -19,211 +23,74 @@ from __future__ import annotations
 
 
 import asyncio
 import asyncio
 import logging
 import logging
-import re
-import ssl
 from collections.abc import Awaitable, Callable
 from collections.abc import Awaitable, Callable
+from dataclasses import replace
 from typing import Any
 from typing import Any
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
-import certifi
 import httpx
 import httpx
 
 
 from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
 from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
-
-logger = logging.getLogger(__name__)
-
-
-# API base: ``api.bambulab.com/v1/design-service`` — the same Bambu Cloud
-# backend that the MakerWorld web UI talks to, but not behind Cloudflare
-# (the website ``makerworld.com`` is, and plain httpx requests there get
-# fingerprinted as bot traffic and served "Please log in"). Confirmed by
-# Pr0zak/YASTL#51 and verified with direct curl.
-MAKERWORLD_API_BASE = "https://api.bambulab.com/v1/design-service"
-MAKERWORLD_HOST = "makerworld.com"  # Used only for URL parsing (input validation)
-MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com")
-
-# Hosts that the iot-service download endpoint may return presigned URLs
-# for. Besides MakerWorld's own CDN, Bambu Cloud also issues AWS S3
-# presigned URLs (e.g. ``s3.us-west-2.amazonaws.com``) — confirmed by
-# Pr0zak/YASTL#52. The suffix check matches any regional S3 endpoint.
-_ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",)
-
-# Client identity sent to MakerWorld / api.bambulab.com. We identify honestly
-# as Bambuddy with a source URL so Bambu can distinguish our traffic from
-# impersonators — the opposite of what the OrcaSlicer fork was called out for
-# in the May 2026 Bambu Lab blog post on cloud access. Verified 2026-05-12 via
-# curl that MakerWorld treats this UA identically to a Firefox UA at the
-# Cloudflare edge (same response shape on /api/v1/design-service/* paths).
-# The Referer is kept because MakerWorld's CSRF / origin-check middleware uses
-# it on some endpoints — that's distinct from client impersonation.
-_CLIENT_HEADERS = {
-    "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
-    "Accept": "text/html,application/json,*/*",
-    "Accept-Language": "en-US,en;q=0.9",
-    "Referer": "https://makerworld.com/",
-}
-
-# Shown whenever Bambu rejects the stored bearer. Bambu's own 401 body is
-# ``{"code":4,"error":"Please login.","message":""}`` and we used to forward that
-# string verbatim, which surfaced as a "Please login." toast on a UI that was
-# simultaneously reporting the user as connected — maximally confusing, and it
-# named no page to go to. Say what happened and where to fix it. Bambu Cloud
-# sign-in lives on the Profiles page (ProfilesPage.tsx, "Cloud Profiles" tab);
-# there is no Settings → Bambu Cloud page, which is what the old fallback text
-# told people to look for.
-_SIGN_IN_EXPIRED_MESSAGE = (
-    "Your Bambu Cloud sign-in has expired. Open the Profiles page and sign in to Bambu Cloud again."
+from backend.app.services.model_providers.base import (
+    ProviderDownload,
+    ProviderDownloadInfo,
+    ProviderResolvedModel,
+    ProviderResourceRef,
+    ProviderService,
+    ProviderStatus,
+)
+from backend.app.services.model_providers.makerworld.auth import is_cloud_token_invalid
+from backend.app.services.model_providers.makerworld.errors import (
+    MakerWorldAuthError,
+    MakerWorldForbiddenError,
+    MakerWorldNotFoundError,
+    MakerWorldUnavailableError,
+    MakerWorldUrlError,
+)
+from backend.app.services.model_providers.makerworld.http import (
+    _ALLOWED_DOWNLOAD_SUFFIXES,
+    _CLIENT_HEADERS,
+    _IMAGE_EXT_TO_MIME,
+    _MAX_3MF_BYTES,
+    _MAX_THUMBNAIL_BYTES,
+    _REFUSED_THUMBNAIL_MIMES,
+    MAKERWORLD_API_BASE,
+    MAKERWORLD_CDN_HOSTS,
+    _download_s3_urllib,
+    _extract_upstream_error,
 )
 )
 
 
-_MODEL_ID_RE = re.compile(r"/models/(\d+)")
-_PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
-_MAX_3MF_BYTES = 200 * 1024 * 1024  # 200 MB hard cap
-_MAX_THUMBNAIL_BYTES = 10 * 1024 * 1024  # 10 MB hard cap — MakerWorld's "thumbnails" can be 2–3 MB source images
-_IMAGE_EXT_TO_MIME = {
-    ".png": "image/png",
-    ".jpg": "image/jpeg",
-    ".jpeg": "image/jpeg",
-    ".gif": "image/gif",
-    ".webp": "image/webp",
-    ".bmp": "image/bmp",
-}
-# Content types we refuse even if the URL extension looks image-y — prevents
-# forwarding an upstream error page or JSON blob with image framing.
-_REFUSED_THUMBNAIL_MIMES = ("text/html", "text/plain", "application/json")
+logger = logging.getLogger(__name__)
 
 
 _shared_http_client: httpx.AsyncClient | None = None
 _shared_http_client: httpx.AsyncClient | None = None
 
 
 
 
-def _s3_ssl_context() -> ssl.SSLContext:
-    """Build the TLS context used for the S3 presigned download (#2562).
-
-    ``urllib.request`` verifies against the *OS* trust store, while httpx —
-    every other network call in Bambuddy — verifies against the bundled
-    ``certifi`` CA bundle. On Windows those two disagree: Python's
-    ``ssl.load_default_certs()`` only enumerates the roots already cached in
-    the Windows ROOT store, and Windows populates that store lazily via
-    CryptoAPI's auto-update, which Python never triggers. If the Amazon root
-    signing the S3 chain isn't cached on that machine yet, verification fails
-    with ``unable to get local issuer certificate`` — even though the
-    api.bambulab.com calls that preceded it (httpx) succeeded.
-
-    Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
-    the app already trusts. Built per call rather than at import so a certifi
-    refresh doesn't require a restart; construction is cheap relative to the
-    download that follows.
-    """
-    return ssl.create_default_context(cafile=certifi.where())
-
-
 def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
 def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
     """Register an app-scoped ``httpx.AsyncClient`` for service reuse.
     """Register an app-scoped ``httpx.AsyncClient`` for service reuse.
 
 
     Same pattern as ``bambu_cloud.set_shared_http_client`` — lets the FastAPI
     Same pattern as ``bambu_cloud.set_shared_http_client`` — lets the FastAPI
     lifespan share one connection pool across per-request service instances.
     lifespan share one connection pool across per-request service instances.
+    Must live in the same module as the service class so ``__init__`` reads
+    the live value rather than an import-time snapshot.
     """
     """
     global _shared_http_client
     global _shared_http_client
     _shared_http_client = client
     _shared_http_client = client
 
 
 
 
-class MakerWorldError(Exception):
-    """Base exception for MakerWorld API errors."""
-
-
-class MakerWorldAuthError(MakerWorldError):
-    """Raised when the endpoint requires a Bambu Cloud token and we don't have
-    one (or the one we sent was rejected). True auth failure."""
-
-
-class MakerWorldForbiddenError(MakerWorldError):
-    """Raised when MakerWorld refuses access despite valid authentication —
-    content-gated (points required, purchase required, region restricted,
-    early-access, etc.). The message includes MakerWorld's own reason text
-    when provided."""
-
-
-class MakerWorldNotFoundError(MakerWorldError):
-    """Raised when a design / profile / instance doesn't exist."""
-
-
-class MakerWorldUnavailableError(MakerWorldError):
-    """Raised on 5xx, network errors, or malformed payloads."""
-
-
-class MakerWorldUrlError(MakerWorldError):
-    """Raised when a URL isn't a makerworld.com model page."""
-
-
-async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes, str]:
-    """Fetch an AWS S3 presigned URL without touching the query string.
+# Shown whenever Bambu rejects the stored bearer. Bambu's own 401 body is
+# ``{"code":4,"error":"Please login.","message":""}`` and we used to forward that
+# string verbatim, which surfaced as a "Please login." toast on a UI that was
+# simultaneously reporting the user as connected — maximally confusing, and it
+# named no page to go to. Say what happened and where to fix it. Bambu Cloud
+# sign-in lives on the Profiles page (ProfilesPage.tsx, "Cloud Profiles" tab);
+# there is no Settings → Bambu Cloud page, which is what the old fallback text
+# told people to look for.
+_SIGN_IN_EXPIRED_MESSAGE = (
+    "Your Bambu Cloud sign-in has expired. Open the Profiles page and sign in to Bambu Cloud again."
+)
 
 
-    ``urllib.request`` passes the URL to the transport verbatim — which is
-    essential for S3 presigned URLs where the signature is computed over
-    the exact query-string bytes. httpx's ``URL`` class and curl_cffi's
-    libcurl layer both normalise encodings and produce
-    ``SignatureDoesNotMatch`` 400s from S3.
 
 
-    Runs the blocking urllib call in a thread executor so we don't stall
-    the event loop.
-    """
-    from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
-
-    # Don't follow redirects: the host allowlist above is only enforced on
-    # the initial URL. A 302 from S3 to any other host would otherwise
-    # transparently bypass the allowlist — so insist S3 resolve directly.
-    class _NoRedirect(HTTPRedirectHandler):
-        def redirect_request(self, *args, **kwargs):  # type: ignore[override]
-            return None
-
-    # HTTPSHandler swaps only the TLS context — the URL still reaches the
-    # transport verbatim, which is what the S3 signature depends on.
-    opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
-
-    def _blocking_fetch() -> bytes:
-        req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
-        with opener.open(req, timeout=60.0) as resp:
-            if resp.status != 200:
-                raise MakerWorldUnavailableError(f"3MF download returned HTTP {resp.status}")
-            data = b""
-            while True:
-                chunk = resp.read(65536)
-                if not chunk:
-                    break
-                data += chunk
-                if len(data) > _MAX_3MF_BYTES:
-                    raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap")
-            return data
-
-    try:
-        data = await asyncio.to_thread(_blocking_fetch)
-    except MakerWorldUnavailableError:
-        raise
-    except Exception as exc:  # noqa: BLE001 — urllib throws a zoo of exceptions
-        raise MakerWorldUnavailableError(f"S3 download failed: {exc}") from exc
-    return data, filename_fallback
-
-
-def _extract_upstream_error(response: httpx.Response) -> str | None:
-    """Pull MakerWorld's own error text out of a 4xx/5xx response body.
-
-    MakerWorld returns ``{"code": N, "error": "text"}`` on auth/perm failures
-    and sometimes ``{"message": "..."}`` on other errors. Returns ``None`` if
-    the body isn't JSON or doesn't have a recognised error field — callers
-    should fall back to a generic message in that case.
-    """
-    try:
-        data = response.json()
-    except ValueError:
-        return None
-    if not isinstance(data, dict):
-        return None
-    for key in ("error", "message", "detail"):
-        value = data.get(key)
-        if isinstance(value, str) and value.strip():
-            return value.strip()
-    return None
-
-
-class MakerWorldService:
+class MakerWorldService(ProviderService):
     """Per-request MakerWorld API client.
     """Per-request MakerWorld API client.
 
 
     Mirrors ``BambuCloudService``'s construction pattern so callers can
     Mirrors ``BambuCloudService``'s construction pattern so callers can
@@ -233,15 +100,26 @@ class MakerWorldService:
 
 
     def __init__(
     def __init__(
         self,
         self,
+        *,
         client: httpx.AsyncClient | None = None,
         client: httpx.AsyncClient | None = None,
         auth_token: str | None = None,
         auth_token: str | None = None,
+        user: Any | None = None,
         on_auth_failure: Callable[[], Awaitable[None]] | None = None,
         on_auth_failure: Callable[[], Awaitable[None]] | None = None,
+        thumbnail_hosts: tuple[str, ...] = MAKERWORLD_CDN_HOSTS,
+        download_hosts: tuple[str, ...] = MAKERWORLD_CDN_HOSTS,
     ):
     ):
         # Fired when Bambu rejects the stored token (401). MakerWorld runs on the
         # Fired when Bambu rejects the stored token (401). MakerWorld runs on the
         # same Bambu Cloud bearer as everything else, so a rejection here means
         # same Bambu Cloud bearer as everything else, so a rejection here means
         # the credential is dead app-wide — see ``build_authenticated_cloud``.
         # the credential is dead app-wide — see ``build_authenticated_cloud``.
         self._on_auth_failure = on_auth_failure
         self._on_auth_failure = on_auth_failure
         self._auth_failure_reported = False
         self._auth_failure_reported = False
+        # SSRF allowlists for the thumbnail proxy and the 3MF download guard.
+        # Default to MakerWorld's CDN hosts; ``MakerWorldProvider.build_service``
+        # passes ``ModelProvider.thumbnail_hosts()`` / ``download_hosts()`` so
+        # the guards are driven by the provider descriptor rather than enforced
+        # by coincidence (interface contract on ``ProviderService``).
+        self._thumbnail_hosts = tuple(thumbnail_hosts)
+        self._download_hosts = tuple(download_hosts)
         if client is not None:
         if client is not None:
             self._client = client
             self._client = client
             self._owns_client = False
             self._owns_client = False
@@ -252,6 +130,7 @@ class MakerWorldService:
             self._client = httpx.AsyncClient(timeout=30.0)
             self._client = httpx.AsyncClient(timeout=30.0)
             self._owns_client = True
             self._owns_client = True
         self._auth_token = auth_token
         self._auth_token = auth_token
+        self._user = user
 
 
     async def close(self) -> None:
     async def close(self) -> None:
         if self._owns_client:
         if self._owns_client:
@@ -283,6 +162,121 @@ class MakerWorldService:
             headers["Authorization"] = f"Bearer {self._auth_token}"
             headers["Authorization"] = f"Bearer {self._auth_token}"
         return headers
         return headers
 
 
+    # ------------------------------------------------------------- interface
+
+    async def get_status(self, db: Any) -> ProviderStatus:
+        """Whether the caller can download: needs a stored, non-rejected Bambu
+        Cloud token. ``credential_rejected`` is the machine-readable expired
+        state; ``auth_error`` names it for humans so the UI can say "your
+        sign-in expired" rather than a bare "sign in"."""
+        has_token = bool(self._auth_token)
+        expired = has_token and await is_cloud_token_invalid(db, self._user)
+        return ProviderStatus(
+            authenticated=has_token,
+            can_download=has_token and not expired,
+            auth_error=_SIGN_IN_EXPIRED_MESSAGE if expired else None,
+            credential_rejected=expired,
+        )
+
+    async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
+        """Fetch full model metadata + the plate list, merging per-instance
+        printer compatibility so the frontend can show "sliced for A1 / also
+        compatible with H2D, P1S" before the user picks a plate."""
+        model_id = int(ref.external_id)
+        design = await self.get_design(model_id)
+        instances_envelope = await self.get_design_instances(model_id)
+
+        # MakerWorld's instances payload is ``{"total": N, "hits": [...]}``;
+        # normalise the null case to an empty list so the frontend doesn't
+        # have to handle null vs [] both ways.
+        instances = instances_envelope.get("hits") or []
+        if not isinstance(instances, list):
+            instances = []
+
+        # /instances/hits omits the per-instance printer compatibility info
+        # that /design.instances[].extention.modelInfo carries. Merge it in.
+        design_instances = design.get("instances") or []
+        if isinstance(design_instances, list):
+            compat_by_id = {}
+            for di in design_instances:
+                if not isinstance(di, dict):
+                    continue
+                iid = di.get("id")
+                if iid is None:
+                    continue
+                ext = (di.get("extention") or {}).get("modelInfo") or {}
+                compat_by_id[iid] = {
+                    "compatibility": ext.get("compatibility"),
+                    "otherCompatibility": ext.get("otherCompatibility"),
+                }
+            for inst in instances:
+                if not isinstance(inst, dict):
+                    continue
+                iid = inst.get("id")
+                extra = compat_by_id.get(iid)
+                if extra:
+                    inst["compatibility"] = extra["compatibility"]
+                    inst["otherCompatibility"] = extra["otherCompatibility"]
+
+        return ProviderResolvedModel(ref=ref, design=design, instances=instances)
+
+    async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
+        """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.
+        """
+        model_id = int(ref.external_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
+        if profile_id is None:
+            for instance in design.get("instances") or []:
+                pid = instance.get("profileId")
+                if isinstance(pid, int) and pid > 0:
+                    profile_id = pid
+                    break
+            if profile_id is None:
+                envelope = await self.get_design_instances(model_id)
+                for hit in envelope.get("hits") or []:
+                    pid = hit.get("profileId")
+                    if isinstance(pid, int) and pid > 0:
+                        profile_id = pid
+                        break
+            if profile_id is None:
+                raise MakerWorldUnavailableError("MakerWorld returned no instances for this model")
+
+        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):
+            raise MakerWorldUnavailableError("MakerWorld did not return a download URL")
+
+        # Raw upstream name — the route layer basenames / percent-decodes it
+        # as defence-in-depth before persisting.
+        raw_name = manifest.get("name")
+        suggested_filename = raw_name if isinstance(raw_name, str) and raw_name.strip() else ""
+
+        return ProviderDownloadInfo(
+            ref=replace(ref, sub_id=str(profile_id)),
+            url=signed_url,
+            suggested_filename=suggested_filename,
+        )
+
+    async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
+        """Fetch the 3MF bytes for a signed URL, returning ``(bytes, filename)``."""
+        file_bytes, download_filename = await self.download_3mf(info.url)
+        return ProviderDownload(file_bytes=file_bytes, filename=download_filename)
+
+    # ---------------------------------------------------------------- endpoints
+
     async def _get_json(self, path: str) -> dict[str, Any]:
     async def _get_json(self, path: str) -> dict[str, Any]:
         """GET ``{MAKERWORLD_API_BASE}{path}`` returning the decoded JSON body.
         """GET ``{MAKERWORLD_API_BASE}{path}`` returning the decoded JSON body.
 
 
@@ -376,49 +370,6 @@ class MakerWorldService:
             )
             )
         return data
         return data
 
 
-    # ------------------------------------------------------------------ URL parse
-
-    @staticmethod
-    def parse_url(url: str) -> tuple[int, int | None]:
-        """Extract ``(model_id, profile_id_or_None)`` from a MakerWorld URL.
-
-        Accepts any of:
-          - ``https://makerworld.com/en/models/1400373``
-          - ``https://makerworld.com/en/models/1400373-slug-with-dashes``
-          - ``https://makerworld.com/en/models/1400373#profileId-1452154``
-          - ``makerworld.com/models/1400373`` (scheme optional)
-
-        Rejects non-makerworld hosts.
-        """
-        if not url or not isinstance(url, str):
-            raise MakerWorldUrlError("URL is empty or not a string")
-        candidate = url.strip()
-        if "://" not in candidate:
-            candidate = "https://" + candidate
-        try:
-            parsed = urlparse(candidate)
-        except ValueError as exc:
-            raise MakerWorldUrlError(f"Could not parse URL: {exc}") from exc
-
-        host = (parsed.hostname or "").lower()
-        if host != MAKERWORLD_HOST and not host.endswith("." + MAKERWORLD_HOST):
-            raise MakerWorldUrlError(f"Not a MakerWorld URL (host={host!r}); expected makerworld.com")
-
-        model_match = _MODEL_ID_RE.search(parsed.path)
-        if not model_match:
-            raise MakerWorldUrlError("URL does not contain a /models/{id} segment")
-        model_id = int(model_match.group(1))
-
-        profile_id: int | None = None
-        if parsed.fragment:
-            profile_match = _PROFILE_ID_RE.search("#" + parsed.fragment)
-            if profile_match:
-                profile_id = int(profile_match.group(1))
-
-        return model_id, profile_id
-
-    # ---------------------------------------------------------------- endpoints
-
     async def get_design(self, model_id: int) -> dict[str, Any]:
     async def get_design(self, model_id: int) -> dict[str, Any]:
         """Fetch full model metadata. Works anonymously.
         """Fetch full model metadata. Works anonymously.
 
 
@@ -510,8 +461,13 @@ class MakerWorldService:
     async def download_3mf(self, signed_url: str) -> tuple[bytes, str]:
     async def download_3mf(self, signed_url: str) -> tuple[bytes, str]:
         """Fetch the 3MF bytes from a signed MakerWorld CDN URL.
         """Fetch the 3MF bytes from a signed MakerWorld CDN URL.
 
 
-        Validates that the URL's host is one of the known MakerWorld CDN hosts
-        (SSRF guard — pattern matches ``_spoolman_helpers.assert_safe_spoolman_url``).
+        Validates that the URL's host is one of the declared download hosts
+        (SSRF guard — driven by ``ModelProvider.download_hosts()`` via
+        ``build_service``, the symmetric counterpart to the thumbnail
+        allowlist) *or* matches ``_ALLOWED_DOWNLOAD_SUFFIXES``, Bambu's S3
+        regional endpoints, which are this provider's own signed-URL family
+        rather than part of the injectable seam; pattern matches
+        ``_spoolman_helpers.assert_safe_spoolman_url``.
         Enforces a 200 MB cap so a single bad response can't exhaust disk.
         Enforces a 200 MB cap so a single bad response can't exhaust disk.
 
 
         Returns ``(file_bytes, suggested_filename)``.
         Returns ``(file_bytes, suggested_filename)``.
@@ -522,7 +478,7 @@ class MakerWorldService:
             raise MakerWorldUrlError(f"Invalid download URL: {exc}") from exc
             raise MakerWorldUrlError(f"Invalid download URL: {exc}") from exc
 
 
         host = (parsed.hostname or "").lower()
         host = (parsed.hostname or "").lower()
-        is_allowed = host in MAKERWORLD_CDN_HOSTS or any(host.endswith(suffix) for suffix in _ALLOWED_DOWNLOAD_SUFFIXES)
+        is_allowed = host in self._download_hosts or any(host.endswith(suffix) for suffix in _ALLOWED_DOWNLOAD_SUFFIXES)
         if not is_allowed:
         if not is_allowed:
             raise MakerWorldUrlError(f"Refusing to download from non-MakerWorld host: {host!r}")
             raise MakerWorldUrlError(f"Refusing to download from non-MakerWorld host: {host!r}")
 
 
@@ -570,9 +526,10 @@ class MakerWorldService:
         SPA's ``img-src`` CSP and keeps users' IP addresses out of
         SPA's ``img-src`` CSP and keeps users' IP addresses out of
         MakerWorld's access logs.
         MakerWorld's access logs.
 
 
-        Validates that the URL's host is one of the known MakerWorld CDN
-        hosts (SSRF guard — same allowlist as :meth:`download_3mf`). Caps
-        payload at 5 MB. Returns ``(bytes, content_type)``; content type
+        Validates that the URL's host is one of the declared thumbnail hosts
+        (SSRF guard — symmetric to :meth:`download_3mf`; both allowlists are
+        fed from the provider descriptor by ``build_service``). Caps
+        payload at 10 MB. Returns ``(bytes, content_type)``; content type
         defaults to ``image/jpeg`` if the upstream didn't set one.
         defaults to ``image/jpeg`` if the upstream didn't set one.
         """
         """
         try:
         try:
@@ -581,7 +538,7 @@ class MakerWorldService:
             raise MakerWorldUrlError(f"Invalid thumbnail URL: {exc}") from exc
             raise MakerWorldUrlError(f"Invalid thumbnail URL: {exc}") from exc
 
 
         host = (parsed.hostname or "").lower()
         host = (parsed.hostname or "").lower()
-        if host not in MAKERWORLD_CDN_HOSTS:
+        if host not in self._thumbnail_hosts:
             raise MakerWorldUrlError(f"Refusing to fetch thumbnail from non-MakerWorld host: {host!r}")
             raise MakerWorldUrlError(f"Refusing to fetch thumbnail from non-MakerWorld host: {host!r}")
 
 
         # ``follow_redirects=False``: the host allowlist above is only
         # ``follow_redirects=False``: the host allowlist above is only

+ 82 - 0
backend/app/services/model_providers/makerworld/url.py

@@ -0,0 +1,82 @@
+"""MakerWorld URL parsing and canonicalisation.
+
+Extracts ``(model_id, profile_id_or_None)`` from model URLs and builds the
+stable dedupe key used as the library ``source_url``. Rejects non-makerworld
+hosts — this is the input-validation surface, so it is deliberately strict.
+"""
+
+from __future__ import annotations
+
+import re
+from urllib.parse import urlparse
+
+from backend.app.services.model_providers.base import ProviderResourceRef
+from backend.app.services.model_providers.makerworld.errors import MakerWorldUrlError
+
+MAKERWORLD_HOST = "makerworld.com"  # Used only for URL parsing (input validation)
+
+_MODEL_ID_RE = re.compile(r"/models/(\d+)")
+_PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
+
+
+def parse_url(url: str) -> ProviderResourceRef:
+    """Extract a :class:`ProviderResourceRef` from a MakerWorld URL.
+
+    Accepts any of:
+      - ``https://makerworld.com/en/models/1400373``
+      - ``https://makerworld.com/en/models/1400373-slug-with-dashes``
+      - ``https://makerworld.com/en/models/1400373#profileId-1452154``
+      - ``makerworld.com/models/1400373`` (scheme optional)
+
+    Rejects non-makerworld hosts.
+    """
+    if not url or not isinstance(url, str):
+        raise MakerWorldUrlError("URL is empty or not a string")
+    candidate = url.strip()
+    if "://" not in candidate:
+        candidate = "https://" + candidate
+    try:
+        parsed = urlparse(candidate)
+    except ValueError as exc:
+        raise MakerWorldUrlError(f"Could not parse URL: {exc}") from exc
+
+    host = (parsed.hostname or "").lower()
+    if host != MAKERWORLD_HOST and not host.endswith("." + MAKERWORLD_HOST):
+        raise MakerWorldUrlError(f"Not a MakerWorld URL (host={host!r}); expected makerworld.com")
+
+    model_match = _MODEL_ID_RE.search(parsed.path)
+    if not model_match:
+        raise MakerWorldUrlError("URL does not contain a /models/{id} segment")
+    model_id = int(model_match.group(1))
+
+    profile_id: int | None = None
+    if parsed.fragment:
+        profile_match = _PROFILE_ID_RE.search("#" + parsed.fragment)
+        if profile_match:
+            profile_id = int(profile_match.group(1))
+
+    return ProviderResourceRef(
+        source_type="makerworld",
+        external_id=str(model_id),
+        sub_id=str(profile_id) if profile_id is not None else None,
+        original_url=url,
+    )
+
+
+def canonical_url(ref: ProviderResourceRef) -> str:
+    """Build a stable dedupe key for a MakerWorld resource.
+
+    Dedupe is keyed per *plate* (profile) rather than per model, since the
+    download returns a specific plate — not the full multi-plate zip — so two
+    different plates of the same design should become two separate library
+    entries. Canonical shape uses the locale-free path with the
+    ``#profileId-`` fragment so all URL variants of the same plate still
+    collapse (e.g. ``/en/models/123-slug?from=search#profileId-456`` and
+    ``/de/models/123#profileId-456`` both map to
+    ``https://makerworld.com/models/123#profileId-456``). Plate-less imports
+    (legacy or whole-design) keep the old model-only shape for backwards
+    compatibility with existing rows.
+    """
+    if ref.sub_id:
+        return f"https://makerworld.com/models/{ref.external_id}#profileId-{ref.sub_id}"
+    return f"https://makerworld.com/models/{ref.external_id}"

+ 60 - 0
backend/app/services/model_providers/registry.py

@@ -0,0 +1,60 @@
+"""Provider registry — maps ``source_type`` / URLs to model providers.
+
+The registry is the routing layer a future *shared* import API uses: a pasted
+URL goes through :meth:`ModelProviderRegistry.find_for_url`, which asks each
+registered provider ``supports_url`` and returns the one that owns it. Today
+the route layer still calls the MakerWorld provider directly (endpoints stay
+at ``/makerworld/*``), but registering providers here keeps the seam ready.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from backend.app.services.model_providers.base import ModelProvider
+
+
+class ModelProviderRegistry:
+    """Holds the registered :class:`ModelProvider` instances.
+
+    Registering is idempotent per provider instance; registering a *different*
+    provider under an already-taken ``source_type`` is an error.
+    """
+
+    def __init__(self) -> None:
+        self._providers: dict[str, ModelProvider] = {}
+
+    def register(self, provider: ModelProvider) -> None:
+        existing = self._providers.get(provider.source_type)
+        if existing is not None and existing is not provider:
+            raise ValueError(f"A model provider for source_type {provider.source_type!r} is already registered")
+        self._providers[provider.source_type] = provider
+
+    def get(self, source_type: str) -> ModelProvider:
+        try:
+            return self._providers[source_type]
+        except KeyError as exc:
+            raise KeyError(f"No model provider registered for source_type {source_type!r}") from exc
+
+    def all(self) -> tuple[ModelProvider, ...]:
+        return tuple(self._providers.values())
+
+    def find_for_url(self, url: str) -> ModelProvider | None:
+        """Return the provider that claims ``url``, or ``None`` if none do.
+
+        Iterates in registration (dict insertion) order; when more than one
+        provider ``supports_url`` the *first registered* one wins. Providers
+        overlap rarely (``host_patterns`` are usually disjoint), so this
+        tie-break is documented rather than policed — a "generic" provider
+        must register after the specific ones it might shadow.
+        """
+        for provider in self._providers.values():
+            if provider.supports_url(url):
+                return provider
+        return None
+
+
+# App-wide registry. Providers register themselves on package import (see
+# ``backend/app/services/model_providers/__init__.py``).
+registry = ModelProviderRegistry()

+ 3 - 1
backend/app/services/mqtt_relay.py

@@ -15,6 +15,8 @@ from typing import Any
 
 
 import paho.mqtt.client as mqtt
 import paho.mqtt.client as mqtt
 
 
+from backend.app.utils.paho_teardown import retire_paho_client
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
@@ -200,7 +202,7 @@ class MQTTRelayService:
                 self._disconnection_event = threading.Event()
                 self._disconnection_event = threading.Event()
                 self.client.disconnect()
                 self.client.disconnect()
                 await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
                 await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
-                self.client.loop_stop()
+                retire_paho_client(self.client, "relay")
             except Exception as e:
             except Exception as e:
                 logger.debug("MQTT disconnect error (ignored): %s", e)
                 logger.debug("MQTT disconnect error (ignored): %s", e)
             finally:
             finally:

+ 3 - 1
backend/app/services/mqtt_smart_plug.py

@@ -13,6 +13,8 @@ from typing import Any
 
 
 import paho.mqtt.client as mqtt
 import paho.mqtt.client as mqtt
 
 
+from backend.app.utils.paho_teardown import retire_paho_client
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
@@ -482,7 +484,7 @@ class MQTTSmartPlugService:
                 self._disconnection_event = threading.Event()
                 self._disconnection_event = threading.Event()
                 self.client.disconnect()
                 self.client.disconnect()
                 await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
                 await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
-                self.client.loop_stop()
+                retire_paho_client(self.client, "smart-plugs")
             except Exception as e:
             except Exception as e:
                 logger.debug("MQTT smart plug disconnect error (ignored): %s", e)
                 logger.debug("MQTT smart plug disconnect error (ignored): %s", e)
             finally:
             finally:

+ 127 - 28
backend/app/services/network_utils.py

@@ -26,8 +26,8 @@ def _is_excluded(name: str) -> bool:
     return any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES)
     return any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES)
 
 
 
 
-def _get_network_interfaces_psutil() -> list[dict]:
-    """Non-Linux path (Windows, macOS, BSD): enumerate interfaces via psutil.
+def _psutil_ipv4_entries(exclude_by_name: bool = False) -> list[dict]:
+    """Every bindable IPv4 address psutil reports, one entry per address.
 
 
     The ioctl request numbers in the Linux path (SIOCGIFADDR 0x8915,
     The ioctl request numbers in the Linux path (SIOCGIFADDR 0x8915,
     SIOCGIFNETMASK 0x891B) and the sockaddr layout they return are
     SIOCGIFNETMASK 0x891B) and the sockaddr layout they return are
@@ -38,10 +38,22 @@ def _get_network_interfaces_psutil() -> list[dict]:
     (``psutil>=6.0.0``) and gives cross-platform name + IPv4 + netmask in one
     (``psutil>=6.0.0``) and gives cross-platform name + IPv4 + netmask in one
     call, so we use it for everything that isn't Linux.
     call, so we use it for everything that isn't Linux.
 
 
+    Secondary addresses are included. psutil returns every unicast address
+    bound to an adapter, so a Windows host with three IPs on one NIC offers
+    three bind targets rather than one (#3121) — the same thing iproute2 gives
+    Linux. ``is_alias`` marks every address after an interface's first, which
+    is the closest Windows equivalent of an iproute2 alias label.
+
     Filters: IPv4 only (matches the Linux path), skip loopback and
     Filters: IPv4 only (matches the Linux path), skip loopback and
     link-local (169.254.0.0/16), skip interfaces psutil reports as down.
     link-local (169.254.0.0/16), skip interfaces psutil reports as down.
-    No name-based exclusion — users may legitimately want to bind a VP to a
-    Hyper-V / WSL / Tailscale / utun virtual adapter.
+
+    Args:
+        exclude_by_name: apply ``EXCLUDED_INTERFACE_PREFIXES``. Only ever true
+            on Linux — those are Linux device names, and a Windows adapter
+            named "Local Area Connection" would match the ``lo`` prefix. The
+            address-class filters above cover the equivalent ground elsewhere,
+            and users may legitimately want to bind a VP to a Hyper-V / WSL /
+            Tailscale / utun adapter.
     """
     """
     try:
     try:
         import psutil
         import psutil
@@ -49,7 +61,7 @@ def _get_network_interfaces_psutil() -> list[dict]:
         logger.warning("psutil not available, interface detection unavailable on this platform")
         logger.warning("psutil not available, interface detection unavailable on this platform")
         return []
         return []
 
 
-    interfaces = []
+    entries = []
     try:
     try:
         addrs_by_iface = psutil.net_if_addrs()
         addrs_by_iface = psutil.net_if_addrs()
         stats_by_iface = psutil.net_if_stats()
         stats_by_iface = psutil.net_if_stats()
@@ -58,10 +70,14 @@ def _get_network_interfaces_psutil() -> list[dict]:
         return []
         return []
 
 
     for name, addrs in addrs_by_iface.items():
     for name, addrs in addrs_by_iface.items():
+        if exclude_by_name and _is_excluded(name):
+            continue
+
         stats = stats_by_iface.get(name)
         stats = stats_by_iface.get(name)
         if stats is not None and not stats.isup:
         if stats is not None and not stats.isup:
             continue
             continue
 
 
+        ipv4_count = 0
         for addr in addrs:
         for addr in addrs:
             if addr.family != socket.AF_INET:
             if addr.family != socket.AF_INET:
                 continue
                 continue
@@ -82,25 +98,53 @@ def _get_network_interfaces_psutil() -> list[dict]:
             except ValueError:
             except ValueError:
                 continue
                 continue
 
 
-            interfaces.append(
+            entries.append(
                 {
                 {
                     "name": name,
                     "name": name,
                     "ip": ip,
                     "ip": ip,
                     "netmask": netmask,
                     "netmask": netmask,
                     "subnet": str(network),
                     "subnet": str(network),
+                    # No label to read on this path, so position is all we
+                    # have: the first address an adapter reports is its
+                    # primary, the rest are secondaries.
+                    "is_alias": ipv4_count > 0,
+                    "label": name,
                 }
                 }
             )
             )
-            # First IPv4 per interface is enough; matches Linux ioctl which
-            # returns only the primary IP (aliases land via get_all_interface_ips
-            # on Linux, which has no Windows analogue worth replicating).
-            break
+            ipv4_count += 1
 
 
-    return interfaces
+    return entries
+
+
+def _get_network_interfaces_psutil() -> list[dict]:
+    """The primary IPv4 of each interface, in ``get_network_interfaces`` shape.
+
+    That function's callers want one subnet per interface — discovery scan
+    targets, the support bundle — not one entry per alias, so the secondary
+    addresses are dropped here rather than never collected.
+    """
+    return [
+        {key: entry[key] for key in ("name", "ip", "netmask", "subnet")}
+        for entry in _psutil_ipv4_entries()
+        if not entry["is_alias"]
+    ]
+
+
+def _sort_interface_entries(entries: list[dict]) -> list[dict]:
+    """Sort in place and return: primary IPs first per interface, then by name."""
+    entries.sort(key=lambda e: (e["name"], e["is_alias"], e["ip"]))
+    return entries
 
 
 
 
-def get_network_interfaces() -> list[dict]:
+def get_network_interfaces(include_excluded: bool = False) -> list[dict]:
     """Get all network interfaces with their IPs and subnets.
     """Get all network interfaces with their IPs and subnets.
 
 
+    Args:
+        include_excluded: keep the interfaces ``EXCLUDED_INTERFACE_PREFIXES``
+            normally hides. That list exists to keep docker0 and friends out
+            of the Virtual Printer's bind dropdown; a caller asking about an
+            address the kernel has already chosen needs the real answer.
+
     Returns:
     Returns:
         List of dicts with name, ip, netmask, subnet, broadcast
         List of dicts with name, ip, netmask, subnet, broadcast
     """
     """
@@ -121,7 +165,7 @@ def get_network_interfaces() -> list[dict]:
             name = iface[1]
             name = iface[1]
 
 
             # Skip excluded interfaces
             # Skip excluded interfaces
-            if _is_excluded(name):
+            if not include_excluded and _is_excluded(name):
                 continue
                 continue
 
 
             try:
             try:
@@ -171,18 +215,25 @@ def get_network_interfaces() -> list[dict]:
     return interfaces
     return interfaces
 
 
 
 
-def get_all_interface_ips() -> list[dict]:
-    """Get all IPs (primary + aliases) for all non-excluded interfaces.
+def get_all_interface_ips(include_excluded: bool = False) -> list[dict]:
+    """Get all IPs (primary + aliases) for every interface, minus the excluded ones.
 
 
     Uses `ip -j addr show` to see secondary/alias IPs that ioctl misses.
     Uses `ip -j addr show` to see secondary/alias IPs that ioctl misses.
-    Falls back to ioctl-based get_network_interfaces() if `ip` is unavailable.
+    Falls back to :func:`_fallback_get_all_ips` wherever `ip` isn't there to
+    ask — which is every non-Linux host.
+
+    Args:
+        include_excluded: see :func:`get_network_interfaces`.
 
 
     Returns:
     Returns:
         List of dicts with name, ip, netmask, subnet, is_alias, label
         List of dicts with name, ip, netmask, subnet, is_alias, label
     """
     """
-    if not _IP_CMD:
-        logger.debug("ip command not found, using ioctl fallback")
-        return _fallback_get_all_ips()
+    # Windows and macOS have no `ip`, so there is nothing to try first. Going
+    # straight to psutil is what lets a Windows NIC carrying three IPs offer
+    # three bind targets instead of one (#3121).
+    if not sys.platform.startswith("linux") or not _IP_CMD:
+        logger.debug("ip command unavailable on this platform, enumerating via psutil")
+        return _fallback_get_all_ips(include_excluded)
 
 
     try:
     try:
         result = subprocess.run(
         result = subprocess.run(
@@ -193,17 +244,17 @@ def get_all_interface_ips() -> list[dict]:
         )
         )
         if result.returncode != 0:
         if result.returncode != 0:
             logger.warning("ip addr show failed: %s", result.stderr)
             logger.warning("ip addr show failed: %s", result.stderr)
-            return _fallback_get_all_ips()
+            return _fallback_get_all_ips(include_excluded)
 
 
         interfaces_data = json.loads(result.stdout)
         interfaces_data = json.loads(result.stdout)
     except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
     except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
         logger.warning("Failed to run ip -j addr show: %s", e)
         logger.warning("Failed to run ip -j addr show: %s", e)
-        return _fallback_get_all_ips()
+        return _fallback_get_all_ips(include_excluded)
 
 
     entries = []
     entries = []
     for iface in interfaces_data:
     for iface in interfaces_data:
         ifname = iface.get("ifname", "")
         ifname = iface.get("ifname", "")
-        if _is_excluded(ifname):
+        if not include_excluded and _is_excluded(ifname):
             continue
             continue
 
 
         ipv4_count = 0
         ipv4_count = 0
@@ -236,23 +287,71 @@ def get_all_interface_ips() -> list[dict]:
             )
             )
             ipv4_count += 1
             ipv4_count += 1
 
 
-    # Sort: primary IPs first per interface, then by interface name
-    entries.sort(key=lambda e: (e["name"], e["is_alias"], e["ip"]))
-    return entries
+    return _sort_interface_entries(entries)
+
+
+def _fallback_get_all_ips(include_excluded: bool = False) -> list[dict]:
+    """Enumerate without iproute2: psutil first, ioctl only if it finds nothing.
 
 
+    psutil is the better answer because it reports secondary addresses, so a
+    host with no `ip` command still gets one bind target per IP instead of per
+    interface. The ioctl wrap below is what such a host used to get (minus the
+    aliases it never saw) and is kept for the one case psutil can't serve: a
+    hand-rolled venv missing the dependency. It only ever runs on Linux, since
+    the ioctl path returns nothing anywhere else.
+    """
+    # EXCLUDED_INTERFACE_PREFIXES are Linux device names; see _psutil_ipv4_entries.
+    exclude_by_name = sys.platform.startswith("linux") and not include_excluded
+    entries = _psutil_ipv4_entries(exclude_by_name=exclude_by_name)
+    if entries:
+        # Deliberately not sorted. psutil's adapter order is what this path has
+        # always returned, and find_interface_for_ip() answers with the first
+        # entry whose subnet holds the target -- which the MQTT bridge uses as
+        # the source IP for the #1429 rewrite and the SSDP proxy as its local
+        # interface. Re-ordering it would quietly re-pick those on a host with
+        # two adapters on one subnet. The iproute2 path sorts because it always
+        # has; only Linux sees that order.
+        return entries
 
 
-def _fallback_get_all_ips() -> list[dict]:
-    """Fallback: wrap get_network_interfaces() result with alias fields."""
     return [
     return [
         {
         {
             **iface,
             **iface,
             "is_alias": False,
             "is_alias": False,
             "label": iface["name"],
             "label": iface["name"],
         }
         }
-        for iface in get_network_interfaces()
+        for iface in get_network_interfaces(include_excluded)
     ]
     ]
 
 
 
 
+def find_local_ipv4_network(local_ip: str) -> ipaddress.IPv4Network | None:
+    """The IPv4 network configured on the local interface holding ``local_ip``.
+
+    An IPv4 address carries no prefix length, so the only way to know how far
+    a LAN reaches is to read the prefix off the interface that owns the
+    address. ``None`` means no local interface claims it, which is the honest
+    answer whenever the platform gives us no interface data at all.
+
+    Nothing is filtered: ``local_ip`` is an address the kernel already picked
+    as a route source, so answering "unknown" because it happens to sit on a
+    bridge named ``br-something`` would be a worse answer than the truth.
+    """
+    try:
+        address = ipaddress.IPv4Address(local_ip)
+    except ValueError:
+        return None
+
+    for iface in get_all_interface_ips(include_excluded=True):
+        if iface.get("ip") != str(address):
+            continue
+        try:
+            return ipaddress.IPv4Network(iface["subnet"], strict=False)
+        except (KeyError, TypeError, ValueError):
+            logger.debug("Interface %s has an unusable subnet %r", iface.get("name"), iface.get("subnet"))
+            return None
+
+    return None
+
+
 def find_interface_for_ip(target_ip: str) -> dict | None:
 def find_interface_for_ip(target_ip: str) -> dict | None:
     """Find which interface is on the same subnet as the target IP.
     """Find which interface is on the same subnet as the target IP.
 
 

+ 9 - 0
backend/app/services/notification_service.py

@@ -398,9 +398,18 @@ class NotificationService:
         # Per-event Priority header (#990). Only set when the user has
         # Per-event Priority header (#990). Only set when the user has
         # explicitly mapped this event to a 1-5 value; otherwise fall through
         # explicitly mapped this event to a 1-5 value; otherwise fall through
         # to the ntfy server's default so existing setups stay unchanged.
         # to the ntfy server's default so existing setups stay unchanged.
+        #
+        # The map is keyed by the provider's toggle column ("on_print_failed"),
+        # because that is what the dialog builds its rows from -- but every
+        # sender is called with the bare event name ("print_failed"), so the
+        # lookup used to miss for every real notification and hit only in tests
+        # that called this method with the prefixed name (issue #3139). Both
+        # spellings are accepted, which also leaves stored configs untouched.
         event_priorities = config.get("event_priorities") or {}
         event_priorities = config.get("event_priorities") or {}
         if event_type and isinstance(event_priorities, dict):
         if event_type and isinstance(event_priorities, dict):
             raw = event_priorities.get(event_type)
             raw = event_priorities.get(event_type)
+            if raw is None and not event_type.startswith("on_"):
+                raw = event_priorities.get(f"on_{event_type}")
             try:
             try:
                 priority = int(raw) if raw is not None else None
                 priority = int(raw) if raw is not None else None
             except (TypeError, ValueError):
             except (TypeError, ValueError):

+ 287 - 42
backend/app/services/plate_thumbnail.py

@@ -25,7 +25,10 @@ from __future__ import annotations
 import io
 import io
 import logging
 import logging
 import re
 import re
+import threading
 import zipfile
 import zipfile
+from collections import defaultdict
+from dataclasses import dataclass, field
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -42,11 +45,33 @@ _PLATE_PNG_SMALL_SIZE = 128
 _BAMBU_GREEN = "#00AE42"
 _BAMBU_GREEN = "#00AE42"
 _BACKGROUND_COLOR = "#1a1a1a"
 _BACKGROUND_COLOR = "#1a1a1a"
 
 
-# Above this vertex count, trimesh.simplify_quadric_decimation runs first.
-# Same cap stl_thumbnail.py uses; matplotlib's Poly3DCollection slows down
-# nonlinearly past ~100k faces and a plate thumbnail doesn't need detail
-# beyond what a 512x512 PNG can resolve.
-_MAX_VERTICES = 100_000
+# Faces the whole plate is rendered with, every instance counted. Render cost
+# is faces, not vertices: matplotlib's Poly3DCollection slows down nonlinearly
+# past ~200k of them, and a 512x512 PNG resolves nothing finer. Roughly what
+# stl_thumbnail's 100k-vertex cap comes to on a closed mesh.
+_RENDER_FACE_BUDGET = 200_000
+
+# A mesh is never decimated below this, however many times it is placed, or a
+# plate of small parts renders as a field of blobs.
+_MIN_FACES_PER_MESH = 200
+
+# Past this many faces after decimation (a plate of thousands of parts, each
+# already at the floor above) the thumbnail is skipped. It is best-effort, and
+# the render's memory grows with every face it is handed (#3135).
+_MAX_PLACED_FACES = 1_000_000
+
+# Bounds on the object graph: components nest, and a file that references
+# itself, or places one part a million times, must not be walked forever.
+_MAX_COMPONENT_DEPTH = 16
+_MAX_PLACEMENTS = 20_000
+
+_MODEL_ROOT = "3D/3dmodel.model"
+
+# One plate render at a time. The slice routes run this off the event loop, and
+# a render holds the whole placed plate in memory; two slices finishing
+# together must not hold two. pyplot is NOT what this guards — the renderer
+# below never touches it (see ``_render_at_size``).
+_render_lock = threading.Lock()
 
 
 # Plate-gcode entries look like ``Metadata/plate_1.gcode``,
 # Plate-gcode entries look like ``Metadata/plate_1.gcode``,
 # ``Metadata/plate_12.gcode`` — anything else is a md5 / json sidecar.
 # ``Metadata/plate_12.gcode`` — anything else is a md5 / json sidecar.
@@ -71,7 +96,7 @@ def inject_plate_thumbnails_if_missing(threemf_bytes: bytes) -> bytes:
             missing = _missing_plate_ids(names)
             missing = _missing_plate_ids(names)
             if not missing:
             if not missing:
                 return threemf_bytes
                 return threemf_bytes
-            if "3D/3dmodel.model" not in names:
+            if _MODEL_ROOT not in names:
                 logger.debug(
                 logger.debug(
                     "plate_thumbnail: sliced 3MF has no 3D/3dmodel.model — skipping (plates %s)",
                     "plate_thumbnail: sliced 3MF has no 3D/3dmodel.model — skipping (plates %s)",
                     sorted(missing),
                     sorted(missing),
@@ -82,7 +107,8 @@ def inject_plate_thumbnails_if_missing(threemf_bytes: bytes) -> bytes:
         return threemf_bytes
         return threemf_bytes
 
 
     try:
     try:
-        large_png, small_png = _render_model_thumbnails(threemf_bytes)
+        with _render_lock:
+            large_png, small_png = _render_model_thumbnails(threemf_bytes)
     except Exception as exc:
     except Exception as exc:
         logger.warning(
         logger.warning(
             "plate_thumbnail: render failed, returning sliced 3MF without injected thumbs: %s",
             "plate_thumbnail: render failed, returning sliced 3MF without injected thumbs: %s",
@@ -146,38 +172,15 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
 
 
     _configure_matplotlib_cache()
     _configure_matplotlib_cache()
 
 
-    import matplotlib
-
-    matplotlib.use("Agg")
-    import matplotlib.pyplot as plt
     import trimesh
     import trimesh
     from matplotlib.colors import LightSource
     from matplotlib.colors import LightSource
     from mpl_toolkits.mplot3d.art3d import Poly3DCollection
     from mpl_toolkits.mplot3d.art3d import Poly3DCollection
 
 
-    loaded = trimesh.load(io.BytesIO(threemf_bytes), file_type="3mf", force="mesh")
-    if loaded is None or not hasattr(loaded, "vertices") or len(loaded.vertices) == 0:
-        logger.debug("plate_thumbnail: trimesh produced empty mesh from 3MF")
+    with zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as zf:
+        placed = _load_plate_geometry(zf, trimesh, _repair_winding)
+    if placed is None:
         return None, None
         return None, None
-
-    mesh = loaded
-    if len(mesh.vertices) > _MAX_VERTICES:
-        try:
-            keep_ratio = _MAX_VERTICES / len(mesh.vertices)
-            target_reduction = max(0.01, min(0.99, 1.0 - keep_ratio))
-            mesh = mesh.simplify_quadric_decimation(target_reduction)
-        except Exception as exc:
-            logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
-
-    # Before the vertices are read, not after: ``scaled`` below is indexed by
-    # ``mesh.faces``, so a repair that ever moves a vertex would leave the two
-    # out of step. Shared with stl_thumbnail rather than copied — the reason
-    # these renderers agree is that they run the same code, not similar code.
-    try:
-        _repair_winding(mesh, trimesh, "plate_thumbnail")
-    except Exception as e:  # best-effort, as the whole module is
-        logger.debug("plate_thumbnail: winding repair skipped (%s)", e)
-
-    vertices = mesh.vertices
+    vertices, faces = placed
     bounds_min = vertices.min(axis=0)
     bounds_min = vertices.min(axis=0)
     bounds_max = vertices.max(axis=0)
     bounds_max = vertices.max(axis=0)
     centered = vertices - (bounds_min + bounds_max) / 2
     centered = vertices - (bounds_min + bounds_max) / 2
@@ -186,7 +189,6 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
 
 
     # ndarray, not a list of lists — shading walks this to build normals, and the
     # ndarray, not a list of lists — shading walks this to build normals, and the
     # list form is ~30x slower to construct. Paid twice per plate: once per size.
     # list form is ~30x slower to construct. Paid twice per plate: once per size.
-    faces = mesh.faces
     poly3d = scaled[faces]
     poly3d = scaled[faces]
 
 
     # Resolved once and shared: both sizes must be lit identically or the 128px
     # Resolved once and shared: both sizes must be lit identically or the 128px
@@ -195,19 +197,263 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
     # ``_shade_kwargs``.
     # ``_shade_kwargs``.
     shade_kw = _shade_kwargs(poly3d, LightSource)
     shade_kw = _shade_kwargs(poly3d, LightSource)
 
 
-    large = _render_at_size(poly3d, _PLATE_PNG_SIZE, plt, Poly3DCollection, shade_kw)
-    small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, plt, Poly3DCollection, shade_kw)
+    large = _render_at_size(poly3d, _PLATE_PNG_SIZE, Poly3DCollection, shade_kw)
+    small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, Poly3DCollection, shade_kw)
     return large, small
     return large, small
 
 
 
 
-def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) -> bytes:
-    """Render the prepared poly3d collection to an in-memory PNG."""
+@dataclass
+class _Object3MF:
+    """One ``<object>``: its own mesh, and the objects it places as components."""
+
+    vertices: object = None  # np.ndarray (n, 3) or None
+    faces: object = None  # np.ndarray (m, 3) or None
+    # (model path or None for "same file", object id, 4x4 transform)
+    components: list = field(default_factory=list)
+
+
+def _local(tag: str) -> str:
+    return tag.rsplit("}", 1)[-1]
+
+
+def _transform(attr: str | None):
+    """A 3MF ``transform`` attribute as a 4x4 matrix for column vectors.
+
+    3MF lists the 3x4 matrix row by row for ROW vectors (``m00 m01 m02 m10 ...
+    m32``, the last three being the translation); transposing it gives the usual
+    column-vector form. Same reading as trimesh's ``_attrib_to_transform``.
+    """
+    import numpy as np
+
+    matrix = np.eye(4)
+    if attr:
+        values = [float(x) for x in attr.split()]
+        if len(values) == 12:
+            matrix[:3, :4] = np.array(values).reshape(4, 3).T
+    return matrix
+
+
+def _parse_model_file(zf: zipfile.ZipFile, path: str) -> tuple[dict[str, _Object3MF], list]:
+    """Every object in one model file, and its build items (root file only).
+
+    Streams the file and drops each element as soon as it is read, so memory
+    stays at the numbers collected rather than an XML tree — one Bambu model
+    file seen in the wild is a single 163 MB mesh. lxml rather than the stdlib
+    parser: ElementTree builds a Python object per vertex and took ~3x as long
+    on that file. The input is untrusted, so entities, DTDs and network access
+    are all off; trimesh, which this replaces here, parses the same files with
+    lxml already.
+    """
+    import numpy as np
+    from lxml import etree
+
+    objects: dict[str, _Object3MF] = {}
+    build: list = []
+    vertices: list = []
+    triangles: list = []
+    components: list = []
+
+    parse = etree.iterparse(
+        io.BytesIO(zf.read(path)),
+        events=("end",),
+        resolve_entities=False,
+        no_network=True,
+        load_dtd=False,
+    )
+    for _event, elem in parse:
+        name = _local(elem.tag) if isinstance(elem.tag, str) else ""
+        if name == "vertex":
+            try:
+                vertices.append((float(elem.get("x")), float(elem.get("y")), float(elem.get("z"))))
+            except (TypeError, ValueError):
+                vertices.append((0.0, 0.0, 0.0))  # keeps the indices of later vertices right
+        elif name == "triangle":
+            try:
+                triangles.append((int(elem.get("v1")), int(elem.get("v2")), int(elem.get("v3"))))
+            except (TypeError, ValueError):
+                pass
+        elif name == "component" and elem.get("objectid") is not None:
+            # ``p:path`` (production extension): the object lives in another
+            # model file. Bambu Studio and OrcaSlicer put every mesh in
+            # ``3D/Objects/`` and place it this way.
+            ref = next((val for key, val in elem.attrib.items() if _local(key) == "path"), None)
+            components.append(
+                (ref.lstrip("/") if ref else None, elem.get("objectid"), _transform(elem.get("transform")))
+            )
+        elif name == "object":
+            obj = _Object3MF(components=components)
+            if triangles:
+                v = np.array(vertices, dtype=float).reshape(-1, 3)
+                f = np.array(triangles, dtype=np.int64).reshape(-1, 3)
+                # A triangle naming a vertex that isn't there would index past
+                # the array at render time; drop it here instead.
+                obj.vertices, obj.faces = v, f[(f >= 0).all(axis=1) & (f < len(v)).all(axis=1)]
+            if elem.get("id") is not None:
+                objects[elem.get("id")] = obj
+            vertices, triangles, components = [], [], []
+        elif name == "item" and elem.get("objectid") is not None:
+            # The production extension allows ``p:path`` here too, naming the
+            # file the object lives in; the root file when absent.
+            ref = next((val for key, val in elem.attrib.items() if _local(key) == "path"), None)
+            build.append((ref.lstrip("/") if ref else None, elem.get("objectid"), _transform(elem.get("transform"))))
+        else:
+            continue
+        # Free what has been read: the element, and the siblings before it that
+        # lxml would otherwise keep attached to the parent.
+        elem.clear()
+        while elem.getprevious() is not None:
+            del elem.getparent()[0]
+    return objects, build
+
+
+def _load_plate_geometry(zf: zipfile.ZipFile, trimesh, repair_winding):
+    """The plate as one (vertices, faces) pair, every instance placed, within budget.
+
+    Not ``trimesh.load``: its 3MF reader re-parses a ``p:path`` component's file
+    for EVERY component that references it and appends the meshes again each
+    time. Bambu Studio and OrcaSlicer write each instance as its own object with
+    one such component, so N copies of a part came back as one mesh holding N
+    copies of every triangle — N² of them once placed — while the vertex count,
+    merged back down, looked normal. 25 bins of 10k faces loaded as 6.4M faces
+    and took 8.4 GB to render (#3135; trimesh 4.12 and 5.1 alike).
+
+    Here each model file is parsed once and each mesh is kept once, decimated
+    once to its share of the face budget, and only then placed per instance.
+    Returns None when there is nothing to draw or the plate is over the ceiling.
+    """
+    import numpy as np
+
+    files: dict[str, dict[str, _Object3MF]] = {}
+    names = set(zf.namelist())
+
+    def objects_in(path: str) -> dict[str, _Object3MF]:
+        if path not in files:
+            files[path] = _parse_model_file(zf, path)[0] if path in names else {}
+        return files[path]
+
+    root_objects, build = _parse_model_file(zf, _MODEL_ROOT)
+    files[_MODEL_ROOT] = root_objects
+
+    placements: dict[tuple[str, str], list] = defaultdict(list)
+    count = 0
+
+    def place(path: str, object_id: str, matrix, depth: int, trail: frozenset) -> None:
+        nonlocal count
+        key = (path, object_id)
+        if depth > _MAX_COMPONENT_DEPTH or key in trail or count > _MAX_PLACEMENTS:
+            return
+        obj = objects_in(path).get(object_id)
+        if obj is None:
+            return
+        if obj.faces is not None and len(obj.faces):
+            placements[key].append(matrix)
+            count += 1
+        for ref, child_id, child_matrix in obj.components:
+            place(ref or path, child_id, matrix @ child_matrix, depth + 1, trail | {key})
+
+    for ref, object_id, matrix in build:
+        place(ref or _MODEL_ROOT, object_id, matrix, 0, frozenset())
+
+    if count > _MAX_PLACEMENTS:
+        logger.info("plate_thumbnail: over %d placed parts, skipping the thumbnail", _MAX_PLACEMENTS)
+        return None
+    if not placements:
+        logger.debug("plate_thumbnail: 3MF places no mesh")
+        return None
+
+    def faces_of(key) -> int:
+        return len(files[key[0]][key[1]].faces)
+
+    def over_ceiling(faces: int) -> bool:
+        if faces <= _MAX_PLACED_FACES:
+            return False
+        logger.info(
+            "plate_thumbnail: %d faces even after decimation (ceiling %d), skipping the thumbnail",
+            faces,
+            _MAX_PLACED_FACES,
+        )
+        return True
+
+    total = sum(faces_of(key) * len(ms) for key, ms in placements.items())
+    scale = min(1.0, _RENDER_FACE_BUDGET / total)
+    targets = {key: max(_MIN_FACES_PER_MESH, int(faces_of(key) * scale)) for key in placements}
+    # What decimation can actually reach: it removes at most 99% of a mesh, so a
+    # part needing more keeps 1% of its faces rather than its target. Checked
+    # before any mesh is built, so a hopeless plate costs nothing but the parse.
+    reachable = sum(
+        min(faces_of(key), max(targets[key], -(-faces_of(key) // 100))) * len(ms) for key, ms in placements.items()
+    )
+    if over_ceiling(reachable):
+        return None
+
+    prepared = []
+    for key, matrices in placements.items():
+        obj = files[key[0]][key[1]]
+        mesh = trimesh.Trimesh(vertices=obj.vertices, faces=obj.faces, process=True)
+        if targets[key] < len(mesh.faces):
+            try:
+                # ``percent`` (the share to REMOVE), the form this module has always
+                # called. ``face_count`` reaches the same size, but on a real 2M-face
+                # model it left the winding inconsistent where ``percent`` did not,
+                # which costs the repair below ~14 s.
+                reduction = 1.0 - targets[key] / len(mesh.faces)
+                mesh = mesh.simplify_quadric_decimation(max(0.01, min(0.99, reduction)))
+            except Exception as exc:
+                logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
+        prepared.append((mesh, matrices))
+
+    # Again on what decimation delivered: it can stop short of its target, or
+    # fail and leave the mesh whole, and the render's memory follows the faces
+    # it is actually handed.
+    if over_ceiling(sum(len(mesh.faces) * len(ms) for mesh, ms in prepared)):
+        return None
+
+    all_vertices = []
+    all_faces = []
+    offset = 0
+    for mesh, matrices in prepared:
+        # Once per mesh, before it is placed: ``faces`` below index these vertices,
+        # so a repair that ever moves one would leave the two out of step. Shared
+        # with stl_thumbnail rather than copied — the renderers agree because they
+        # run the same code.
+        try:
+            repair_winding(mesh, trimesh, "plate_thumbnail")
+        except Exception as e:  # best-effort, as the whole module is
+            logger.debug("plate_thumbnail: winding repair skipped (%s)", e)
+        vertices = np.asarray(mesh.vertices, dtype=float)
+        faces = np.asarray(mesh.faces, dtype=np.int64)
+        for matrix in matrices:
+            all_vertices.append(vertices @ matrix[:3, :3].T + matrix[:3, 3])
+            # A mirroring transform turns every triangle inside out; flip the
+            # winding back so shading still sees the outside.
+            placed = faces[:, ::-1] if np.linalg.det(matrix[:3, :3]) < 0 else faces
+            all_faces.append(placed + offset)
+            offset += len(vertices)
+
+    return np.vstack(all_vertices), np.vstack(all_faces)
+
+
+def _render_at_size(poly3d, size: int, Poly3DCollection, shade_kw: dict) -> bytes:
+    """Render the prepared poly3d collection to an in-memory PNG.
+
+    Matplotlib's object API, not pyplot. This runs in a worker thread (#3135)
+    while stl_thumbnail renders through pyplot on the event loop, and pyplot's
+    figure registry and "current figure" are process-global: its
+    ``subplots_adjust`` would lay out whichever figure the other thread made
+    last, and neither lock placement is acceptable — held on the loop it stalls
+    the server for the whole plate render. A ``Figure`` with its own Agg canvas
+    shares nothing, so the two can run at once.
+    """
     # Local, like every other import in this module, so importing plate_thumbnail
     # Local, like every other import in this module, so importing plate_thumbnail
     # in an environment without matplotlib still works. stl_thumbnail's own
     # in an environment without matplotlib still works. stl_thumbnail's own
     # module level is import-light, so this costs nothing after the first call.
     # module level is import-light, so this costs nothing after the first call.
+    from matplotlib.backends.backend_agg import FigureCanvasAgg
+    from matplotlib.figure import Figure
+
     from backend.app.services.stl_thumbnail import VIEW_AZIM_DEG, VIEW_ELEV_DEG
     from backend.app.services.stl_thumbnail import VIEW_AZIM_DEG, VIEW_ELEV_DEG
 
 
-    fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
+    fig = Figure(figsize=(size / 100, size / 100), dpi=100)
+    FigureCanvasAgg(fig)
     fig.patch.set_facecolor(_BACKGROUND_COLOR)
     fig.patch.set_facecolor(_BACKGROUND_COLOR)
     ax = fig.add_subplot(111, projection="3d")
     ax = fig.add_subplot(111, projection="3d")
     ax.set_facecolor(_BACKGROUND_COLOR)
     ax.set_facecolor(_BACKGROUND_COLOR)
@@ -230,7 +476,7 @@ def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) ->
     ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
     ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
     ax.set_axis_off()
     ax.set_axis_off()
     ax.grid(False)
     ax.grid(False)
-    plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
+    fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
 
 
     buf = io.BytesIO()
     buf = io.BytesIO()
     fig.savefig(
     fig.savefig(
@@ -242,7 +488,6 @@ def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) ->
         pad_inches=0.05,
         pad_inches=0.05,
         dpi=100,
         dpi=100,
     )
     )
-    plt.close(fig)
     return buf.getvalue()
     return buf.getvalue()
 
 
 
 

+ 1 - 1
backend/app/services/preset_resolver.py

@@ -29,7 +29,6 @@ import logging
 from fastapi import HTTPException
 from fastapi import HTTPException
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.api.routes.cloud import get_stored_token
 from backend.app.api.routes.orca_cloud import _build_authenticated_service as _build_orca_service
 from backend.app.api.routes.orca_cloud import _build_authenticated_service as _build_orca_service
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.local_preset import LocalPreset
@@ -40,6 +39,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudError,
     BambuCloudError,
     BambuCloudService,
     BambuCloudService,
 )
 )
+from backend.app.services.bambu_cloud_credentials import get_stored_token
 from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
 from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)

+ 530 - 70
backend/app/services/print_scheduler.py

@@ -6,6 +6,7 @@ import logging
 import time
 import time
 import uuid
 import uuid
 from collections import deque
 from collections import deque
+from collections.abc import Mapping
 from dataclasses import dataclass
 from dataclasses import dataclass
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
 from pathlib import Path
 from pathlib import Path
@@ -58,11 +59,13 @@ from backend.app.services.printer_manager import (
     supports_drying_while_printing,
     supports_drying_while_printing,
 )
 )
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.services.smart_plug_manager import smart_plug_manager
+from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.color_utils import perceptual_color_distance
 from backend.app.utils.color_utils import perceptual_color_distance
 from backend.app.utils.filament_types import canonical_filament_type
 from backend.app.utils.filament_types import canonical_filament_type
 from backend.app.utils.filename import derive_remote_filename
 from backend.app.utils.filename import derive_remote_filename
 from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.printer_models import (
 from backend.app.utils.printer_models import (
+    is_dual_nozzle_model,
     is_gcode_compatible,
     is_gcode_compatible,
     is_nozzle_rack_model,
     is_nozzle_rack_model,
     normalize_printer_model,
     normalize_printer_model,
@@ -471,6 +474,77 @@ def _mapping_is_all_unresolved(mapping: list | None) -> bool:
 _EXTERNAL_TRAY_ID_MIN = 254
 _EXTERNAL_TRAY_ID_MIN = 254
 
 
 
 
+def _consumed_mapping_entries(mapping: list | None, required: list[dict] | None) -> list | None:
+    """The ``mapping`` entries for the slots this plate actually prints.
+
+    ``required`` comes from ``extract_filament_requirements``, which drops any
+    filament with ``used_g <= 0`` — so a slot_id present there is one the plate
+    consumes, and one absent from it is padding. That distinction is why this
+    decision lives here and not in the MQTT command builder: a ``-1`` in the
+    mapping means either "this plate does not print filament N" or "we never
+    worked out which tray", and only the plate's own filament list separates
+    them. The builder sees both as the same byte, which is how a plate whose one
+    printed filament sat on the external spool went out as `use_ams=true` with a
+    mapping of nothing but -1 and stalled at preheat until the firmware gave up
+    with 07FF_8012 (#3087).
+
+    Returns None whenever the two cannot be lined up — no mapping, no parsed
+    requirements, or a requirement the mapping is too short to cover — so every
+    caller falls back to existing behaviour rather than acting on a guess.
+    """
+    if not isinstance(mapping, list) or not mapping or not required:
+        return None
+    entries = []
+    for filament in required:
+        slot_id = filament.get("slot_id")
+        if not isinstance(slot_id, int) or not 1 <= slot_id <= len(mapping):
+            # The mapping and the requirements disagree about how many filaments
+            # the file has. They came from different reads, so judge nothing.
+            return None
+        entries.append(mapping[slot_id - 1])
+    return entries or None
+
+
+def _is_external_tray(tray_id) -> bool:
+    """True for an explicit external-spool selection (254/255), not for an
+    unresolved slot and not for an AMS tray."""
+    if tray_id is None:
+        return False
+    try:
+        return int(tray_id) >= _EXTERNAL_TRAY_ID_MIN
+    except (TypeError, ValueError):
+        return False
+
+
+def _might_be_dual_nozzle(printer_model: str | None, status) -> bool:
+    """Whether this printer could have two extruders, judged generously.
+
+    On a dual-nozzle printer ``use_ams`` is nozzle routing rather than an
+    AMS on/off flag — H2D Pro firmware reads it as an extruder index — which is
+    why the MQTT command builder skips its own use_ams reconcile there. Anything
+    that might be dual-nozzle therefore keeps whatever ``use_ams`` it arrived
+    with, external spools or not.
+
+    Deliberately over-eager: a wrong "yes" only means this printer keeps the
+    behaviour it has always had, while a wrong "no" would rewrite a field that
+    steers which nozzle prints. The model name is the first answer (it is what
+    the command builder falls back to as well), then the same live evidence the
+    dispatcher's extruder annotation uses — a second nozzle reporting a
+    diameter, a populated ``ams_extruder_map``, or more than one external feed,
+    since a single-nozzle printer has exactly one.
+    """
+    if is_dual_nozzle_model(printer_model):
+        return True
+    nozzles = getattr(status, "nozzles", None) or []
+    if len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""):
+        return True
+    raw = getattr(status, "raw_data", None) or {}
+    if raw.get("ams_extruder_map"):
+        return True
+    vt_trays = raw.get("vt_tray") or []
+    return isinstance(vt_trays, list) and len(vt_trays) > 1
+
+
 def _int_or(value, default: int) -> int:
 def _int_or(value, default: int) -> int:
     """``int(value)``, or ``default`` when the field is missing or junk.
     """``int(value)``, or ``default`` when the field is missing or junk.
 
 
@@ -1186,6 +1260,127 @@ class PrintScheduler:
             )
             )
             busy_printers: set[int] = {pid for (pid,) in busy_result.all() if pid is not None}
             busy_printers: set[int] = {pid for (pid,) in busy_result.all() if pid is not None}
 
 
+            # Why each printer left this pass, recorded where the decision is
+            # made rather than re-derived when the summary is logged. #3018's
+            # bundle shows what the old summary produced: "printer 1 not
+            # available -- connected=True, state=IDLE" immediately followed by a
+            # dispatch to printer 1. Two things went wrong at once. The line read
+            # live state at log time, which by then no longer matched the state
+            # the decision was made on; and `busy_printers` holds both printers
+            # that cannot take work and printers this pass has claimed for it,
+            # which are opposite facts. It is the first line anyone greps for
+            # "why did my item not go out", so it has to say which.
+            busy_reasons: dict[int, str] = dict.fromkeys(busy_printers, "an item is already printing on it")
+
+            # Printers this pass is dispatching to. They are in busy_printers so
+            # nothing else in the pass targets them -- that is a reservation, not
+            # an obstruction, and the summary says so.
+            claimed_printers: set[int] = set()
+
+            def mark_busy(printer_id: int, reason: str) -> None:
+                """Take ``printer_id`` out of this pass, recording why.
+
+                First reason wins: a printer already excluded by a stronger fact
+                -- a print running on it -- must not be relabelled by a weaker
+                check that ran later and would have excluded it anyway.
+                """
+                busy_printers.add(printer_id)
+                busy_reasons.setdefault(printer_id, reason)
+
+            def claim_printer(printer_id: int) -> None:
+                """Reserve ``printer_id`` for an item this pass is dispatching."""
+                claimed_printers.add(printer_id)
+                mark_busy(printer_id, "selected for dispatch in this pass")
+
+            # The user-facing half of `busy_reasons` (#3074). The same decisions
+            # worded for a different audience: the log wants "still inside its
+            # post-dispatch hold window", the queue row wants to know the printer
+            # is taken and nothing is broken. Only the cases that would read wrong
+            # as a plain "Busy" are recorded here; the rest fall back to it.
+            item_hold_reasons: dict[int, str] = {}
+
+            # Names and models for the printers this pass may have to write a
+            # waiting reason about, read once rather than per skip per tick. The
+            # model-based branch already has its names from `_printers_for_model`.
+            pinned_printer_ids = {i.printer_id for i in items if i.printer_id}
+            pinned_printers: dict[int, tuple[str, str]] = {}
+            if pinned_printer_ids:
+                pinned_rows = await db.execute(
+                    select(Printer.id, Printer.name, Printer.model).where(Printer.id.in_(pinned_printer_ids))
+                )
+                pinned_printers = {pid: (name or "", model or "") for pid, name, model in pinned_rows.all()}
+
+            def printer_label(printer_id: int) -> str:
+                """What to call this printer in a queue row."""
+                entry = pinned_printers.get(printer_id)
+                return (entry[0] if entry else "") or f"printer {printer_id}"
+
+            async def hold_item(item: PrintQueueItem, reason: str | None, *, notify: bool = True) -> None:
+                """Record why *item* is not going out, in the words the queue row shows.
+
+                The fixed-printer branch's single writer for ``waiting_reason``
+                (#3074). Before this, the sensor interlock was the only thing that
+                wrote the field there, so an item pinned to a printer that was
+                merely printing sat at `pending` with nothing to show for it —
+                indistinguishable from a queue that had stopped working — while
+                the same job queued as "Any <model>" explained itself.
+
+                Every exit from that branch now calls this, which is also what
+                replaced the interlock's old habit of clearing the field up front:
+                a lifted hold cannot leave "Waiting on Enclosure Door" standing,
+                because whichever exit runs next overwrites it and the dispatch
+                path clears it.
+
+                A notification goes out when this item starts asking for
+                something, and only then: the new reason needs the user, and what
+                it replaced did not. "What it replaced did not" has to include a
+                busy-only reason, not just an empty one. The sequence this branch
+                actually produces is a print running (``Busy: X1C-01``) and then
+                the plate it left behind (``Waiting for plate confirmation``), and
+                testing "was the field empty" would call that no transition at all
+                and stay quiet through the one case worth saying out loud.
+
+                The cost is that a printer dropping offline, coming back busy and
+                dropping again asks twice rather than once. That is the honest
+                reading — it went wrong twice — and the alternative was a rule
+                that never fired for the case this was built for.
+
+                *notify* is how a caller opts out. The sensor interlock does: it
+                has never sent this notification, and a change about what the
+                queue *displays* is not the place to start (#1148).
+                """
+                if item.waiting_reason == reason:
+                    return
+                # Busy-only and empty are the same thing here: neither is the
+                # queue asking the user for anything.
+                was_asking = bool(item.waiting_reason) and not self._is_busy_only(item.waiting_reason)
+                item.waiting_reason = reason
+                await db.commit()
+                if not notify or not reason or was_asking or self._is_busy_only(reason):
+                    return
+                try:
+                    job_name = await self._get_job_name(db, item)
+                    entry = pinned_printers.get(item.printer_id) if item.printer_id else None
+                    await notification_service.on_queue_job_waiting(
+                        job_name=job_name,
+                        target_model=(entry[1] if entry else "") or "",
+                        waiting_reason=reason,
+                        db=db,
+                    )
+                except Exception as e:
+                    # A queue that cannot say why it is waiting is the bug being
+                    # fixed here; a queue that stops dispatching because a
+                    # notification provider is down would be a worse one.
+                    logger.debug("Waiting notification failed for item %s: %s", item.id, e)
+
+            async def hold_for_printer(
+                item: PrintQueueItem, printer_id: int, log_reason: str, item_reason: str
+            ) -> None:
+                """Take *printer_id* out of this pass and tell the item's owner why."""
+                mark_busy(printer_id, log_reason)
+                item_hold_reasons.setdefault(printer_id, item_reason)
+                await hold_item(item, item_reason)
+
             # Defense-in-depth (#1157): augment busy_printers with any printer
             # Defense-in-depth (#1157): augment busy_printers with any printer
             # still in its post-dispatch hold window. Empirically, the DB seed
             # still in its post-dispatch hold window. Empirically, the DB seed
             # above can miss in-flight items in a multi-plate batch — same-file
             # above can miss in-flight items in a multi-plate batch — same-file
@@ -1196,7 +1391,7 @@ class PrintScheduler:
             # timing.
             # timing.
             for held_printer_id in list(self._dispatch_holds.keys()):
             for held_printer_id in list(self._dispatch_holds.keys()):
                 if self._printer_in_dispatch_hold(held_printer_id):
                 if self._printer_in_dispatch_hold(held_printer_id):
-                    busy_printers.add(held_printer_id)
+                    mark_busy(held_printer_id, "still inside its post-dispatch hold window")
 
 
             # Exclude printers whose upload is still in flight from an earlier
             # Exclude printers whose upload is still in flight from an earlier
             # pass (#2602). The row is `pending` until the upload finishes and
             # pass (#2602). The row is `pending` until the upload finishes and
@@ -1205,7 +1400,7 @@ class PrintScheduler:
             # busy_printers, its auto-drying) out of the pass during the upload.
             # busy_printers, its auto-drying) out of the pass during the upload.
             for _task, inflight_pid in self._inflight.values():
             for _task, inflight_pid in self._inflight.values():
                 if inflight_pid is not None:
                 if inflight_pid is not None:
-                    busy_printers.add(inflight_pid)
+                    mark_busy(inflight_pid, "an upload to it is still in flight")
 
 
             # Snapshot taken here, before the item loop adds anything (#2801).
             # Snapshot taken here, before the item loop adds anything (#2801).
             #
             #
@@ -1310,11 +1505,18 @@ class PrintScheduler:
                     if sched.tzinfo is None:
                     if sched.tzinfo is None:
                         sched = sched.replace(tzinfo=timezone.utc)
                         sched = sched.replace(tzinfo=timezone.utc)
                     if sched > datetime.now(timezone.utc):
                     if sched > datetime.now(timezone.utc):
+                        # Waiting on the clock, not on a printer.
+                        await hold_item(item, None)
                         skip_reasons["scheduled_future"] = skip_reasons.get("scheduled_future", 0) + 1
                         skip_reasons["scheduled_future"] = skip_reasons.get("scheduled_future", 0) + 1
                         continue
                         continue
 
 
                 # Skip items that require manual start
                 # Skip items that require manual start
                 if item.manual_start:
                 if item.manual_start:
+                    # Waiting on the user, not on a printer. Cleared here because
+                    # this is the last pass that will look at the row: a staged
+                    # item never reaches the branches below again, so a reason
+                    # left from before it was staged would stand forever (#3074).
+                    await hold_item(item, None)
                     skip_reasons["manual_start"] = skip_reasons.get("manual_start", 0) + 1
                     skip_reasons["manual_start"] = skip_reasons.get("manual_start", 0) + 1
                     continue
                     continue
 
 
@@ -1325,24 +1527,35 @@ class PrintScheduler:
                     # to shut the enclosure" need to read differently, and only
                     # to shut the enclosure" need to read differently, and only
                     # one of them is something the user can fix.
                     # one of them is something the user can fix.
                     #
                     #
-                    # The interlock is the only thing that writes a
-                    # waiting_reason on this branch — the model-based branch
-                    # nulls it at the moment it assigns a printer — so any
-                    # reason still standing once the hold lifts is stale and is
-                    # cleared here. Doing it at dispatch instead would leave a
-                    # shut door reading "Waiting on Enclosure Door" for as long
-                    # as the printer stayed busy with something else.
+                    # It used to be the only thing that wrote a waiting_reason on
+                    # this branch, and it cleared the field up front so a lifted
+                    # hold could not leave a shut door reading "Waiting on
+                    # Enclosure Door". `hold_item` carries that guarantee now —
+                    # every exit below writes — so the clear is gone and the
+                    # interlock is an ordinary hold like the rest (#3074).
                     interlock_reason = interlocked.get(item.printer_id)
                     interlock_reason = interlocked.get(item.printer_id)
-                    reason = f"Waiting on {interlock_reason}" if interlock_reason else None
-                    if item.waiting_reason != reason:
-                        item.waiting_reason = reason
-                        await db.commit()
                     if interlock_reason:
                     if interlock_reason:
+                        # Silent, exactly as it has always been. #1148 built this
+                        # as a hold that shows on the row, never as an alert, and
+                        # routing it through the shared writer must not quietly
+                        # turn every open door into a notification.
+                        await hold_item(item, f"Waiting on {interlock_reason}", notify=False)
                         skip_reasons["sensor_interlock"] = skip_reasons.get("sensor_interlock", 0) + 1
                         skip_reasons["sensor_interlock"] = skip_reasons.get("sensor_interlock", 0) + 1
                         continue
                         continue
 
 
                     # Specific printer assignment (existing behavior)
                     # Specific printer assignment (existing behavior)
                     if item.printer_id in busy_printers:
                     if item.printer_id in busy_printers:
+                        # Whatever took the printer out of this pass — a print
+                        # already running on it, a post-dispatch hold, an upload
+                        # still in flight, an item ahead of this one in the same
+                        # pass — reads the same way from the queue: the printer is
+                        # taken and this item is in line for it. The exceptions
+                        # that do not (an offline printer, say) recorded their own
+                        # wording in `item_hold_reasons` when they held it.
+                        await hold_item(
+                            item,
+                            item_hold_reasons.get(item.printer_id) or f"Busy: {printer_label(item.printer_id)}",
+                        )
                         continue
                         continue
 
 
                     # Check if printer is idle
                     # Check if printer is idle
@@ -1374,16 +1587,36 @@ class PrintScheduler:
                                 printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
                                 printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
                             else:
                             else:
                                 logger.warning("Could not power on printer %s via smart plug", item.printer_id)
                                 logger.warning("Could not power on printer %s via smart plug", item.printer_id)
-                                busy_printers.add(item.printer_id)
+                                await hold_for_printer(
+                                    item,
+                                    item.printer_id,
+                                    "smart-plug power-on failed",
+                                    f"Offline: {printer_label(item.printer_id)} — the smart plug could not power it on",
+                                )
                                 continue
                                 continue
                         else:
                         else:
-                            # No plug or auto_on disabled
-                            busy_printers.add(item.printer_id)
+                            # No plug or auto_on disabled. Worded exactly as the
+                            # model-based branch words it (#2786): this is the one
+                            # entry on that list the user has to act on, because
+                            # Bambuddy will never switch this printer on itself.
+                            await hold_for_printer(
+                                item,
+                                item.printer_id,
+                                "offline, with no smart plug to power it on",
+                                f"Offline, no Auto On smart plug: {printer_label(item.printer_id)}",
+                            )
                             continue
                             continue
 
 
                     # Check if printer is idle (busy with another print)
                     # Check if printer is idle (busy with another print)
                     if not printer_idle:
                     if not printer_idle:
-                        busy_printers.add(item.printer_id)
+                        await hold_for_printer(
+                            item,
+                            item.printer_id,
+                            "not idle",
+                            self._pinned_hold_reason(
+                                item.printer_id, printer_label(item.printer_id), require_plate_clear
+                            ),
+                        )
                         continue
                         continue
 
 
                     # Drying blocks the queue, if the user asked it to. A hold
                     # Drying blocks the queue, if the user asked it to. A hold
@@ -1392,7 +1625,14 @@ class PrintScheduler:
                     if self._drying_in_progress.get(item.printer_id) and await self._get_bool_setting(
                     if self._drying_in_progress.get(item.printer_id) and await self._get_bool_setting(
                         db, "queue_drying_block"
                         db, "queue_drying_block"
                     ):
                     ):
-                        busy_printers.add(item.printer_id)
+                        # Busy-shaped on purpose: the cycle ends on its own and
+                        # the job goes out, so there is nothing to alert about.
+                        await hold_for_printer(
+                            item,
+                            item.printer_id,
+                            "drying, and drying is set to block the queue",
+                            f"Busy: {printer_label(item.printer_id)} (drying)",
+                        )
                         continue
                         continue
 
 
                     # Check condition (previous print success)
                     # Check condition (previous print success)
@@ -1401,6 +1641,8 @@ class PrintScheduler:
                             item.status = "skipped"
                             item.status = "skipped"
                             item.error_message = "Previous print failed or was aborted"
                             item.error_message = "Previous print failed or was aborted"
                             item.completed_at = datetime.now(timezone.utc)
                             item.completed_at = datetime.now(timezone.utc)
+                            # Not pending any more, so not waiting for anything.
+                            item.waiting_reason = None
                             await db.commit()
                             await db.commit()
                             logger.info("Skipped queue item %s - previous print failed", item.id)
                             logger.info("Skipped queue item %s - previous print failed", item.id)
 
 
@@ -1430,6 +1672,10 @@ class PrintScheduler:
                     # promote the item to manual_start so the user must
                     # promote the item to manual_start so the user must
                     # acknowledge via the ▶ button (which re-checks live).
                     # acknowledge via the ▶ button (which re-checks live).
                     if await self._block_on_filament_deficit(db, item):
                     if await self._block_on_filament_deficit(db, item):
+                        # Now staged for the user to start by hand, and the row
+                        # shows the filament-short badge instead. Cleared because
+                        # a staged item never reaches this branch again.
+                        await hold_item(item, None)
                         continue
                         continue
 
 
                     # Hold this item back for the next pass rather than racing
                     # Hold this item back for the next pass rather than racing
@@ -1438,7 +1684,12 @@ class PrintScheduler:
                     # its place in this printer's queue.
                     # its place in this printer's queue.
                     if _library_row_conflict(item):
                     if _library_row_conflict(item):
                         skip_reasons["library_row_in_use"] = skip_reasons.get("library_row_in_use", 0) + 1
                         skip_reasons["library_row_in_use"] = skip_reasons.get("library_row_in_use", 0) + 1
-                        busy_printers.add(item.printer_id)
+                        await hold_for_printer(
+                            item,
+                            item.printer_id,
+                            "holding its place while another item releases a library row",
+                            f"Busy: {printer_label(item.printer_id)}",
+                        )
                         continue
                         continue
 
 
                     # Print takes priority: stop a cycle Bambuddy armed, now
                     # Print takes priority: stop a cycle Bambuddy armed, now
@@ -1466,9 +1717,14 @@ class PrintScheduler:
                     # Queue the dispatch instead of running it here — see
                     # Queue the dispatch instead of running it here — see
                     # _dispatch_selected(). busy_printers still gets the printer
                     # _dispatch_selected(). busy_printers still gets the printer
                     # immediately, so nothing else in this pass can target it.
                     # immediately, so nothing else in this pass can target it.
+                    #
+                    # The reason goes first: this item is not waiting for anything
+                    # any more, and the model-based branch clears its own at the
+                    # equivalent moment (#3074).
+                    await hold_item(item, None)
                     _claim_library_row(item)
                     _claim_library_row(item)
                     dispatch_ids.append(item.id)
                     dispatch_ids.append(item.id)
-                    busy_printers.add(item.printer_id)
+                    claim_printer(item.printer_id)
 
 
                     # SJF starvation guard: mark items that were jumped
                     # SJF starvation guard: mark items that were jumped
                     if sjf_enabled and item.print_time_seconds is not None:
                     if sjf_enabled and item.print_time_seconds is not None:
@@ -1674,7 +1930,7 @@ class PrintScheduler:
 
 
                         _claim_library_row(item)
                         _claim_library_row(item)
                         dispatch_ids.append(item.id)
                         dispatch_ids.append(item.id)
-                        busy_printers.add(printer_id)
+                        claim_printer(printer_id)
 
 
                         # SJF starvation guard: mark model-based items that were jumped
                         # SJF starvation guard: mark model-based items that were jumped
                         if sjf_enabled and item.print_time_seconds is not None:
                         if sjf_enabled and item.print_time_seconds is not None:
@@ -1701,20 +1957,24 @@ class PrintScheduler:
             # useless for working out why an item did not go out.
             # useless for working out why an item did not go out.
             if skip_reasons:
             if skip_reasons:
                 logger.info("Queue skip summary: %s", skip_reasons)
                 logger.info("Queue skip summary: %s", skip_reasons)
-            if busy_printers:
-                # Log why each printer was busy (first time it was checked)
-                for pid in busy_printers:
-                    state = printer_manager.get_status(pid)
-                    connected = printer_manager.is_connected(pid)
-                    awaiting = printer_manager.is_awaiting_plate_clear(pid)
-                    state_name = state.state if state else "NO_STATUS"
-                    logger.info(
-                        "Queue: printer %d not available — connected=%s, state=%s, awaiting_plate_clear=%s",
-                        pid,
-                        connected,
-                        state_name,
-                        awaiting,
-                    )
+            for pid in sorted(busy_printers):
+                reason = busy_reasons.get(pid, "no reason recorded")
+                if pid in claimed_printers:
+                    logger.info("Queue: printer %d reserved — %s", pid, reason)
+                    continue
+                # The three live fields stay, because they are what someone
+                # reading a bundle wants next -- but they are labelled as read
+                # now, not as the state the decision was made on, which is what
+                # made the old line contradict itself.
+                state = printer_manager.get_status(pid)
+                logger.info(
+                    "Queue: printer %d unavailable — %s (now: connected=%s, state=%s, awaiting_plate_clear=%s)",
+                    pid,
+                    reason,
+                    printer_manager.is_connected(pid),
+                    state.state if state else "NO_STATUS",
+                    printer_manager.is_awaiting_plate_clear(pid),
+                )
 
 
             # Keep-warm is a comfort feature; dispatch is not. It sits between
             # Keep-warm is a comfort feature; dispatch is not. It sits between
             # selection and `_launch_uploads`, so anything raising here would
             # selection and `_launch_uploads`, so anything raising here would
@@ -3672,6 +3932,28 @@ class PrintScheduler:
             logger.debug("Printer %d: not idle — state=%s", printer_id, state.state)
             logger.debug("Printer %d: not idle — state=%s", printer_id, state.state)
         return idle
         return idle
 
 
+    @staticmethod
+    def _pinned_hold_reason(printer_id: int, printer_name: str, require_plate_clear: bool) -> str:
+        """Why a connected, non-idle printer cannot take this job, for the queue row (#3074).
+
+        Only ever asked about a printer :meth:`_is_printer_idle` has just refused
+        and that the fixed-printer branch has already found connected, so the two
+        offline cases answer at their own exits and never arrive here.
+
+        The default is the model-based branch's ``Busy:`` wording, which
+        :meth:`_is_busy_only` reads as "resolves itself, stay quiet". That is also
+        the right answer for the connected-but-no-telemetry second or two after a
+        reconnect: the model-based branch has always reported it that way, and it
+        is not something to wake anybody up for.
+
+        A plate nobody has confirmed is the one case here that does not resolve
+        itself — somebody has to walk over to the printer — so it is worded as
+        itself and allowed to notify.
+        """
+        if require_plate_clear and printer_manager.is_awaiting_plate_clear(printer_id):
+            return f"Waiting for plate confirmation: {printer_name}"
+        return f"Busy: {printer_name}"
+
     async def _get_setting(self, db: AsyncSession, key: str) -> str | None:
     async def _get_setting(self, db: AsyncSession, key: str) -> str | None:
         """Read a setting value from the database."""
         """Read a setting value from the database."""
         result = await db.execute(select(Settings).where(Settings.key == key))
         result = await db.execute(select(Settings).where(Settings.key == key))
@@ -3736,6 +4018,61 @@ class PrintScheduler:
                 continue
                 continue
         return out
         return out
 
 
+    # Materials whose AMS spelling differs from the key the tables above use.
+    # Bambu labels nylon "PA" while its own composites spell the family out, so
+    # PA6, PA11, PA12 and PAHT would otherwise miss a table with a perfectly
+    # good PA row (#3067).
+    #
+    # Mirrors DRYING_MATERIAL_ALIASES in frontend/src/utils/dryingPresets.ts.
+    # The drying popover has resolved these correctly since #2774 and the
+    # scheduler never did, which is exactly why #3067's reporter could dry a
+    # PA6-CF spool by hand while auto-drying skipped it every pass.
+    #
+    # PPA is here too. Polyphthalamide is a distinct polymer rather than a grade
+    # of nylon, so it is the one entry that is a judgement rather than a
+    # spelling -- but it is an aromatic polyamide, it absorbs moisture the same
+    # way, and PA's row is the hottest the table has. Drying it there is closer
+    # to right than not drying it at all, which is what it got before.
+    FILAMENT_KEY_ALIASES: dict[str, str] = {
+        "NYLON": "PA",
+        "PA6": "PA",
+        "PA11": "PA",
+        "PA12": "PA",
+        "PAHT": "PA",
+        "PPA": "PA",
+    }
+
+    @classmethod
+    def _resolve_filament_key(cls, tray_type: str | None, table: Mapping[str, object]) -> str | None:
+        """The key in *table* that answers for this tray's material, or None.
+
+        The printer reports the material in ``tray_type``, and it spells filled
+        and foamed variants out: PLA-CF, PETG-CF, ABS-GF, PLA-AERO, PA6-CF. The
+        tables here are keyed by base material, so matching the raw string alone
+        found a row for 8 of the 41 types a printer can report and skipped the
+        rest -- silently, because every caller reads "no row" as "nothing to do
+        for this tray". Auto-drying therefore ignored every composite spool on
+        the install (#3067).
+
+        Exact match first, so a table the user has extended with a row of its
+        own -- ``PA6-CF`` at a temperature they picked -- still wins over the
+        base material's. Then the suffix is dropped, then the alias map above
+        answers for the polyamide spellings.
+
+        Returns None rather than a default: what to do with an unrecognised
+        material differs per caller, and only the caller knows whether "no row"
+        means skip the tray or fall back to a catch-all. Nothing here invents a
+        temperature for a material the table does not list.
+        """
+        raw = cls._normalize_filament_type(tray_type or "")
+        if not raw:
+            return None
+        for candidate in (raw, raw.split("-")[0]):
+            key = cls.FILAMENT_KEY_ALIASES.get(candidate, candidate)
+            if key in table:
+                return key
+        return None
+
     @staticmethod
     @staticmethod
     def resolve_humidity_threshold(trays: list[dict], thresholds: dict[str, int], fallback: int) -> int:
     def resolve_humidity_threshold(trays: list[dict], thresholds: dict[str, int], fallback: int) -> int:
         """Resolve the effective humidity threshold for an AMS unit (#1605).
         """Resolve the effective humidity threshold for an AMS unit (#1605).
@@ -3755,8 +4092,10 @@ class PrintScheduler:
             tray_type = str(tray.get("tray_type") or "").strip()
             tray_type = str(tray.get("tray_type") or "").strip()
             if not tray_type:
             if not tray_type:
                 continue
                 continue
-            base_type = tray_type.split()[0].upper()
-            candidates.append(thresholds.get(base_type, default))
+            # A composite carries its base material's threshold when it has no
+            # row of its own, the same way it takes its drying preset (#3067).
+            key = PrintScheduler._resolve_filament_key(tray_type, thresholds)
+            candidates.append(thresholds[key] if key is not None else default)
         if not candidates:
         if not candidates:
             return default
             return default
         return min(candidates)
         return min(candidates)
@@ -3779,9 +4118,17 @@ class PrintScheduler:
             tray_type = tray.get("tray_type", "")
             tray_type = tray.get("tray_type", "")
             if not tray_type:
             if not tray_type:
                 continue
                 continue
-            # Normalize filament type for preset lookup (e.g., "PLA Basic" -> "PLA")
-            base_type = tray_type.split()[0].upper()
-            preset = presets.get(base_type)
+            # "PLA Basic" -> PLA, and "PA6-CF" -> PA rather than nothing at all,
+            # which is what stopped auto-drying on every composite spool (#3067).
+            base_type = self._resolve_filament_key(tray_type, presets)
+            if base_type is None:
+                continue
+            # The table is user-editable JSON with no per-row validation, so a
+            # row can be present and empty. That has always meant "skip this
+            # material", and it has to keep meaning it: the reads below fall
+            # back to 55C/12h per missing field, which is a temperature nobody
+            # chose and would deform a PLA spool.
+            preset = presets[base_type]
             if not preset:
             if not preset:
                 continue
                 continue
 
 
@@ -3951,21 +4298,12 @@ class PrintScheduler:
 
 
                 dry_time = int(ams_data.get("dry_time") or 0)
                 dry_time = int(ams_data.get("dry_time") or 0)
 
 
-                # Read humidity — prefer humidity_raw (actual %) over humidity (index 1-5)
-                humidity = None
-                h_raw = ams_data.get("humidity_raw")
-                if h_raw is not None:
-                    try:
-                        humidity = int(h_raw)
-                    except (ValueError, TypeError):
-                        pass
-                if humidity is None:
-                    h_idx = ams_data.get("humidity")
-                    if h_idx is not None:
-                        try:
-                            humidity = int(h_idx)
-                        except (ValueError, TypeError):
-                            pass
+                # Read humidity as a percentage. The 1-5 index is never
+                # substituted: it is inverted, and being unable to exceed any
+                # threshold it would read as "dry" forever (#3140). ``None``
+                # already means "skip this unit" everywhere below.
+                humidity_pct = ams_humidity_percent(ams_data)
+                humidity = int(round(humidity_pct)) if humidity_pct is not None else None
                 unit_key = (pid, ams_id)
                 unit_key = (pid, ams_id)
                 unit_state = self._auto_dry_units.get(unit_key)
                 unit_state = self._auto_dry_units.get(unit_key)
 
 
@@ -4581,8 +4919,18 @@ class PrintScheduler:
         """Reduce the printer's tray_type to a preset-lookup key. Mirrors the
         """Reduce the printer's tray_type to a preset-lookup key. Mirrors the
         existing drying-preset normalisation (split-at-space, upper-case) so
         existing drying-preset normalisation (split-at-space, upper-case) so
         the two maps share vocabulary — "PLA Basic" → "PLA", "PA-CF" stays
         the two maps share vocabulary — "PLA Basic" → "PLA", "PA-CF" stays
-        "PA-CF" (no space to split on)."""
-        return tray_type.split()[0].upper() if tray_type else ""
+        "PA-CF" (no space to split on).
+
+        This is the first stage of ``_resolve_filament_key``, which goes on to
+        drop the suffix and consult the alias map; on its own it only decides
+        what the tray is called, not which row answers for it.
+
+        Indexing the split rather than testing the input: a tray_type of spaces
+        is truthy and splits to nothing, so the old ``if tray_type`` guard let
+        it through to an IndexError.
+        """
+        words = (tray_type or "").split()
+        return words[0].upper() if words else ""
 
 
     def _target_for_tray_type(self, tray_type: str | None, targets: dict[str, int]) -> int:
     def _target_for_tray_type(self, tray_type: str | None, targets: dict[str, int]) -> int:
         """Per-filament chamber target for one tray's reported type, or 0 when
         """Per-filament chamber target for one tray's reported type, or 0 when
@@ -4593,14 +4941,19 @@ class PrintScheduler:
         not the 0 an unknown type falls to. The specific type is still tried
         not the 0 an unknown type falls to. The specific type is still tried
         first, so PETG-CF and PA-CF keep the hotter rows they are listed with
         first, so PETG-CF and PA-CF keep the hotter rows they are listed with
         (#2902).
         (#2902).
+
+        That lookup is now the shared one, which adds the polyamide aliases on
+        top of the suffix it already dropped -- so PA6-CF reaches PA's row here
+        too, rather than the catch-all it was landing on (#3067).
         """
         """
-        normalised = self._normalize_filament_type(tray_type or "")
-        if not normalised:
+        if not self._normalize_filament_type(tray_type or ""):
             return 0
             return 0
-        target = targets.get(normalised)
-        if target is None:
-            target = targets.get(normalised.split("-")[0], targets.get("DEFAULT", 0))
-        return target
+        key = self._resolve_filament_key(tray_type, targets)
+        if key is not None:
+            return targets[key]
+        # A type the map does not list at all, which is not the same as a tray
+        # with nothing in it -- that already returned 0 above.
+        return targets.get("DEFAULT", 0)
 
 
     def _derive_chamber_target(
     def _derive_chamber_target(
         self,
         self,
@@ -5081,6 +5434,34 @@ class PrintScheduler:
                 return False
                 return False
         return True
         return True
 
 
+    def _preheat_flap_to_cooling(self, item_id: int, printer: Printer) -> None:
+        """Put the airduct flap back to cooling for a print that wants no chamber heat.
+
+        The full preheat stage does this as part of its own dispatch: an H2D
+        left in heating mode by the ABS job before it would otherwise cook the
+        PLA that follows. The skip path never reaches that code, so it calls
+        this instead -- one idempotent MQTT command, no waiting, and nothing to
+        add to the rollback pin, because a flap set to cooling for a print that
+        needs no heat is where it should have been either way.
+
+        Best-effort like everything else in the stage: a refused command logs
+        and the dispatch carries on.
+        """
+        model = printer.model or ""
+        if not supports_airduct(model):
+            return
+        state = printer_manager.get_status(printer.id)
+        current = getattr(state, "airduct_mode", None) if state else None
+        if current == _AIRDUCT_MODE_COOLING:
+            return
+        client = printer_manager.get_client(printer.id)
+        if client is None:
+            return
+        try:
+            client.set_airduct_mode("cooling")
+        except Exception as exc:
+            logger.warning("Queue item %s: preheat-skip airduct cooling failed: %s", item_id, exc)
+
     async def _preheat_and_soak(
     async def _preheat_and_soak(
         self,
         self,
         db: AsyncSession,
         db: AsyncSession,
@@ -5104,9 +5485,14 @@ class PrintScheduler:
           2. Chamber target — `item.preheat_chamber_target_override` if non-null;
           2. Chamber target — `item.preheat_chamber_target_override` if non-null;
              else max of `preheat_filament_targets[normalize(t.tray_type)]`
              else max of `preheat_filament_targets[normalize(t.tray_type)]`
              across the trays `item.ams_mapping` names (every loaded slot when
              across the trays `item.ams_mapping` names (every loaded slot when
-             it names none); else 0 (skips chamber phase, keeps bed phase +
-             soak timer).
-          3. Three hardware tiers branch the wait loop:
+             it names none).
+          3. A target of 0 off the filament map skips the whole stage: the
+             materials this print loads want no chamber, so there is nothing to
+             soak for and the bed phase would only delay the upload (#3041).
+             An explicit 0 typed into the per-item override, or a per-item
+             'on', still runs the bed phase and the soak — both are the user
+             asking for a warm bed in so many words.
+          4. Three hardware tiers branch the wait loop:
              - Chamber heater (H2C/H2D/H2DPro/H2S/X2D/X1E via supports_chamber_heater):
              - Chamber heater (H2C/H2D/H2DPro/H2S/X2D/X1E via supports_chamber_heater):
                send M141 to the resolved target, then wait for the chamber sensor
                send M141 to the resolved target, then wait for the chamber sensor
                to reach it (or the max-wait timeout to elapse).
                to reach it (or the max-wait timeout to elapse).
@@ -5142,9 +5528,10 @@ class PrintScheduler:
         # Chamber target resolution:
         # Chamber target resolution:
         #   1. Explicit per-item override beats everything (user knows best).
         #   1. Explicit per-item override beats everything (user knows best).
         #   2. Otherwise derive from the filament types this print loads, via
         #   2. Otherwise derive from the filament types this print loads, via
-        #      the per-filament target map. PLA-only print derives 0 → chamber
-        #      phase auto-skips without the user touching anything, even when
-        #      an ASA spool is sitting in another slot of the same AMS (#2886).
+        #      the per-filament target map. A PLA-only print derives 0 and the
+        #      block below skips the stage without the user touching anything,
+        #      even when an ASA spool is sitting in another slot of the same
+        #      AMS (#2886).
         explicit_target = getattr(item, "preheat_chamber_target_override", None)
         explicit_target = getattr(item, "preheat_chamber_target_override", None)
         if explicit_target is not None and explicit_target > 0:
         if explicit_target is not None and explicit_target > 0:
             chamber_target = int(explicit_target)
             chamber_target = int(explicit_target)
@@ -5157,6 +5544,31 @@ class PrintScheduler:
             chamber_target = self._derive_chamber_target(printer, targets, item)
             chamber_target = self._derive_chamber_target(printer, targets, item)
             chamber_source = "filament-map"
             chamber_source = "filament-map"
 
 
+        # Nothing to preheat *for*. A zero that came out of the filament map is
+        # the map saying this print's materials want no chamber conditioning --
+        # PLA, PETG, TPU and PVA all sit at 0 by default. Running the stage
+        # anyway heated the bed and then held it for the full soak, which
+        # delayed every PLA dispatch by minutes and bought nothing: the print's
+        # own G-code sets the bed the moment it starts, so preheating it here
+        # only moves that heating ahead of the upload instead of overlapping
+        # with it, and the soak has no chamber to condition (#3041).
+        #
+        # An explicit statement from the user still runs the stage. Forcing the
+        # per-item override to 'on', or typing a chamber target of exactly 0,
+        # both mean "preheat the bed for this print" -- the second is
+        # documented as doing precisely that. Only the automatic path, the
+        # global toggle plus the filament map, short-circuits here.
+        if chamber_target <= 0 and chamber_source == "filament-map" and override != "on":
+            logger.info(
+                "Queue item %s: preheat skipped -- the loaded filaments derive no chamber "
+                "target, so there is nothing to soak for (override=%s model=%s)",
+                item.id,
+                override,
+                printer.model or "",
+            )
+            self._preheat_flap_to_cooling(item.id, printer)
+            return True
+
         bed_target = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
         bed_target = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
         if bed_target <= 0:
         if bed_target <= 0:
             # No bed temperature in the slicer metadata. When the print needs a
             # No bed temperature in the slicer metadata. When the print needs a
@@ -6632,6 +7044,54 @@ class PrintScheduler:
             if slot_extruders:
             if slot_extruders:
                 nozzle_slot_extruders = json.dumps(slot_extruders)
                 nozzle_slot_extruders = json.dumps(slot_extruders)
 
 
+        # Every filament this plate prints is on the external spool -> the print
+        # must go out with use_ams=False. The firmware answers use_ams=true plus
+        # a mapping it cannot resolve with 07FF_8012 "Failed to get AMS mapping
+        # table", which is what held the reporter's P1S at Heatbed preheating
+        # for ten minutes before it gave up (#3087). The MQTT command builder
+        # already downgrades a mapping that is *only* external ([254]), but a
+        # multi-filament project pads the slots this plate does not print with
+        # -1 — BambuStudio's own convention — and down there a -1 is
+        # indistinguishable from a slot that never resolved, which must never be
+        # sent to the spool holder (#2589). Here the plate's filament list says
+        # which is which, so the answer is exact rather than a guess.
+        #
+        # Deliberately narrow: this fires only when every consumed slot is an
+        # explicit 254/255. A consumed slot that did not resolve leaves use_ams
+        # alone and the firmware still rejects the print, exactly as today. And
+        # only for single-nozzle printers, mirroring the builder's own reconcile
+        # — on a dual-nozzle machine use_ams is which extruder to feed, not
+        # whether to use the AMS, so it is not ours to rewrite.
+        effective_use_ams = item.use_ams
+        if (
+            effective_use_ams
+            and ams_mapping
+            and file_path is not None
+            # Cheap gate before opening the file: with nothing on the spool
+            # holder anywhere in the mapping, no subset of it can be all
+            # external, so most dispatches never pay for the parse. The
+            # isinstance also keeps a malformed stored mapping (a bare number
+            # from a hand-edited row) failing where it always failed, in the
+            # command builder, rather than here.
+            and isinstance(ams_mapping, list)
+            and any(_is_external_tray(t) for t in ams_mapping)
+            and not _might_be_dual_nozzle(printer.model, pre_status)
+        ):
+            from backend.app.services.filament_requirements import extract_filament_requirements
+
+            consumed = _consumed_mapping_entries(
+                ams_mapping, extract_filament_requirements(file_path, plate_id=item.plate_id or 1)
+            )
+            if consumed and all(_is_external_tray(t) for t in consumed):
+                effective_use_ams = False
+                logger.info(
+                    "Queue item %s: every filament plate %s prints is on the external spool "
+                    "(mapping %s) — dispatching with use_ams=False (#3087)",
+                    item.id,
+                    item.plate_id or 1,
+                    ams_mapping,
+                )
+
         # Start the print with AMS mapping, plate_id and print options.
         # Start the print with AMS mapping, plate_id and print options.
         # nozzle_mapping rides through verbatim — JSON string captured from
         # nozzle_mapping rides through verbatim — JSON string captured from
         # Bambu Studio's project_file on VP intake (#1780); the MQTT layer
         # Bambu Studio's project_file on VP intake (#1780); the MQTT layer
@@ -6648,7 +7108,7 @@ class PrintScheduler:
             vibration_cali=item.vibration_cali,
             vibration_cali=item.vibration_cali,
             layer_inspect=item.layer_inspect,
             layer_inspect=item.layer_inspect,
             timelapse=effective_timelapse,
             timelapse=effective_timelapse,
-            use_ams=item.use_ams,
+            use_ams=effective_use_ams,
             nozzle_offset_cali=item.nozzle_offset_cali,
             nozzle_offset_cali=item.nozzle_offset_cali,
             nozzle_mapping=item.nozzle_mapping
             nozzle_mapping=item.nozzle_mapping
             or (json.dumps(resolved_nozzle_mapping) if resolved_nozzle_mapping else None),
             or (json.dumps(resolved_nozzle_mapping) if resolved_nozzle_mapping else None),

+ 14 - 2
backend/app/services/print_storage.py

@@ -4,8 +4,8 @@ Bambuddy reads a print's 3MF, cover and timelapse off the printer over implicit
 FTPS on port 990. On every Bambu model that port serves **external storage only**
 FTPS on port 990. On every Bambu model that port serves **external storage only**
 -- the SD card or USB stick. It is not a view of the printer's filesystem.
 -- the SD card or USB stick. It is not a view of the printer's filesystem.
 
 
-H2-series and P2S firmware default to keeping the sliced file on internal eMMC
-instead, and BambuStudio uploads there over a separate service on port 6000
+H2-series, P2S and X2D firmware default to keeping the sliced file on internal
+eMMC instead, and BambuStudio uploads there over a separate service on port 6000
 (the "BambuTunnelLocal" protocol -- see #2762, which tracks implementing it).
 (the "BambuTunnelLocal" protocol -- see #2762, which tracks implementing it).
 The dispatch says where it went: the ``project_file`` command carries ``url``,
 The dispatch says where it went: the ``project_file`` command carries ``url``,
 which is ``ftp://<name>`` for external storage and ``brtc://emmc/<name>`` for
 which is ``ftp://<name>`` for external storage and ``brtc://emmc/<name>`` for
@@ -84,6 +84,18 @@ REASON_INTERNAL_HISTORY = "internal_history"
 # retry is worth scheduling (#2957).
 # retry is worth scheduling (#2957).
 REASON_FTPS_COOLOFF = "ftps_cooloff"
 REASON_FTPS_COOLOFF = "ftps_cooloff"
 
 
+# Also not a storage verdict, and the file's location was never in question
+# here either: the print went to external storage, FTPS served it, and the
+# transfer still did not finish inside its budget. At print start the printer is
+# also handling MQTT, the camera and the job upload, and a large 3MF does not
+# reliably complete against that -- #3063's reporter watched the same 19MB file
+# download successfully three times in the two minutes after the archive flow
+# gave up on it. Like the cool-off above and unlike the three storage verdicts,
+# this one is temporary and worth a retry; unlike the cool-off, nothing has to
+# expire first. Stamped by the print-start handler, which is the only place that
+# knows an attempt was made and failed in transit rather than answering 550.
+REASON_FTP_TRANSFER_FAILED = "ftp_transfer_failed"
+
 # Where a sliced file has ever been found over FTPS, in the order the sweep in
 # Where a sliced file has ever been found over FTPS, in the order the sweep in
 # `main.py` tries them -- root first, which is where A1/P1-series uploads land
 # `main.py` tries them -- root first, which is where A1/P1-series uploads land
 # (#972), then `/cache`, which is where the H2D keeps its copy of an eMMC job
 # (#972), then `/cache`, which is where the H2D keeps its copy of an eMMC job

+ 240 - 32
backend/app/services/printer_diagnostic.py

@@ -2,7 +2,7 @@
 
 
 Runs the checks a maintainer performs by hand when triaging a
 Runs the checks a maintainer performs by hand when triaging a
 "printer won't connect / won't print" report — port reachability, LAN
 "printer won't connect / won't print" report — port reachability, LAN
-developer mode, Docker network mode, subnet match, and MQTT credentials —
+developer mode, container network mode, subnet match, and MQTT credentials —
 so users can self-diagnose setup problems instead of opening an issue.
 so users can self-diagnose setup problems instead of opening an issue.
 
 
 See the 2026-05-21 issue-triage analysis: ~1/3 of closed issues were
 See the 2026-05-21 issue-triage analysis: ~1/3 of closed issues were
@@ -12,16 +12,21 @@ user-side setup errors clustered on exactly these causes.
 import asyncio
 import asyncio
 import ipaddress
 import ipaddress
 import logging
 import logging
+import os
 import socket
 import socket
 import ssl
 import ssl
+import subprocess
+import sys
+from pathlib import Path
 
 
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
 from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
 from backend.app.services.bambu_ftp import find_remote_file_async
 from backend.app.services.bambu_ftp import find_remote_file_async
 from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
 from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
 from backend.app.services.camera import get_camera_port
 from backend.app.services.camera import get_camera_port
-from backend.app.services.discovery import is_running_in_docker
+from backend.app.services.discovery import OCI_RUNTIMES, detect_container_runtime
 from backend.app.services.ftp_profiles import get_ftp_profile
 from backend.app.services.ftp_profiles import get_ftp_profile
+from backend.app.services.network_utils import find_local_ipv4_network
 from backend.app.services.print_storage import (
 from backend.app.services.print_storage import (
     REASON_INTERNAL_STORAGE,
     REASON_INTERNAL_STORAGE,
     StorageVerdict,
     StorageVerdict,
@@ -203,48 +208,202 @@ def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
     return camera_port, "RTSPS"
     return camera_port, "RTSPS"
 
 
 
 
-def _detect_docker_network_mode() -> str:
-    """Detect Docker network mode.
+# Interfaces a container engine creates on the *host*. Seeing one of them
+# means we are in the host's network namespace.
+_HOST_INFRA_PREFIXES = ("docker", "br-", "veth", "virbr", "podman", "cni-", "cni_")
 
 
-    In host mode the container shares the host network namespace, so Docker
-    infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
-    mode the container only sees its own eth0.
+
+def _has_native_interface() -> bool:
+    """True if some interface here was created in this network namespace.
+
+    A NAT-networked container is handed one end of a veth pair per attached
+    network, and a veth's ``iflink`` points at its peer's index in the *other*
+    namespace, so it never equals its own ``ifindex``. An interface where the
+    two agree was made here — a physical NIC, a bridge, a VLAN — which a
+    container with its own namespace does not get.
+
+    tun/tap devices are skipped: a container can legitimately run its own
+    WireGuard or Tailscale client, and that tun would otherwise read as
+    evidence of a namespace it is not evidence of.
+    """
+    try:
+        entries = [(idx, name) for idx, name in socket.if_nameindex() if name != "lo"]
+    except Exception:
+        return False
+
+    for index, name in entries:
+        # Never user input: the kernel's own interface table, and never a path.
+        iface = Path("/sys/class/net") / name  # SEC-PATH-OK: name from socket.if_nameindex()
+        if (iface / "tun_flags").exists():
+            continue
+        try:
+            ifindex = (iface / "ifindex").read_text().strip()
+            iflink = (iface / "iflink").read_text().strip()
+        except (OSError, ValueError):
+            continue
+        # sysfs is tagged by network namespace, but a container given a bind
+        # mount of the host's /sys sees the host's interfaces under names that
+        # may collide with its own. Reading a different interface's numbers
+        # would be reading another namespace's answer, so require that the
+        # entry found here is the one the kernel just named.
+        if ifindex != str(index):
+            continue
+        if ifindex == iflink:
+            return True
+    return False
+
+
+def _detect_container_network_mode(runtime: str | None) -> str | None:
+    """Return "host", "bridge", or None when it genuinely cannot be told.
+
+    The first rule is the original Docker one and is kept exactly: a Docker
+    *host* always has a docker0, so a container that can see it shares the
+    host's namespace. It says nothing about Podman, which on a host running
+    no bridge containers creates no such interface at all — which is how a
+    host-networked Podman container came to be told it was on bridge
+    networking (#3092).
+
+    The second rule is the general form of the same idea and is what answers
+    for Podman. The third is the fallback the first rule always implied: an
+    OCI container that can see neither is isolated, which is what bridge
+    networking means.
     """
     """
     try:
     try:
         for _idx, name in socket.if_nameindex():
         for _idx, name in socket.if_nameindex():
-            if name.startswith(("docker", "br-", "veth", "virbr")):
+            if name.startswith(_HOST_INFRA_PREFIXES):
                 return "host"
                 return "host"
     except Exception:
     except Exception:
         pass
         pass
-    return "bridge"
+    if _has_native_interface():
+        return "host"
+    if runtime in OCI_RUNTIMES:
+        return "bridge"
+    return None
 
 
 
 
-def _get_host_ip() -> str | None:
-    """Best-effort IPv4 address the Bambuddy host routes from."""
+def _host_source_ip(destination_ip: str) -> str | None:
+    """The local IPv4 address Bambuddy would send from toward ``destination_ip``.
+
+    Asking about the printer's own address rather than a fixed far-away one
+    matters on any host with more than one NIC: the source for a route to the
+    internet is simply not the source for a route to the printer, and
+    comparing the printer against the wrong interface is a warning about
+    nothing (#3092).
+
+    Literals only. ``connect()`` on a name would resolve it, and this runs on
+    the event loop; ``_same_subnet`` rejects names anyway, so nothing is lost.
+    """
+    try:
+        if ipaddress.ip_address(destination_ip).version != 4:
+            return None
+    except ValueError:
+        return None
     try:
     try:
         s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
         s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
         try:
         try:
             # No packets are sent; this just picks the routing-table source IP.
             # No packets are sent; this just picks the routing-table source IP.
-            s.connect(("10.255.255.255", 1))
+            s.connect((destination_ip, 1))
             return s.getsockname()[0]
             return s.getsockname()[0]
         finally:
         finally:
             s.close()
             s.close()
     except Exception:
     except Exception:
+        # Fail soft: this is a diagnostic, and an unroutable address or an
+        # exhausted fd table must leave the check skipped, not 500 the page.
         return None
         return None
 
 
 
 
-def _same_subnet(ip_a: str, ip_b: str) -> bool | None:
-    """True/False if both are IPv4 literals in the same /24; None if undeterminable."""
+def _same_subnet(printer_ip: str, host_ip: str) -> bool | None:
+    """Is ``printer_ip`` inside the network configured on Bambuddy's ``host_ip``?
+
+    None means undeterminable — a name instead of an IPv4 literal, or no
+    local interface claiming ``host_ip``.
+
+    An address does not carry its prefix, and this used to supply ``/24`` for
+    both sides. That is the most common LAN and not the only one: on the
+    reporter's ``192.168.96.0/22`` it declared a printer four hundred
+    addresses away to be on a different network and told him to go configure
+    routing between two halves of one subnet (#3092). The prefix is read off
+    the interface that owns the source address instead.
+    """
     try:
     try:
-        addr_a = ipaddress.ip_address(ip_a)
-        addr_b = ipaddress.ip_address(ip_b)
+        printer_addr = ipaddress.ip_address(printer_ip)
+        host_addr = ipaddress.ip_address(host_ip)
     except ValueError:
     except ValueError:
         return None
         return None
-    if addr_a.version != 4 or addr_b.version != 4:
+    if printer_addr.version != 4 or host_addr.version != 4:
         return None
         return None
-    net_a = ipaddress.ip_network(f"{addr_a}/24", strict=False)
-    net_b = ipaddress.ip_network(f"{addr_b}/24", strict=False)
-    return net_a == net_b
+
+    network = find_local_ipv4_network(str(host_addr))
+    if network is None:
+        return None
+    return printer_addr in network
+
+
+# macOS attributes Local Network permission (TCC) to a process's code
+# signature, and judges a launchd-spawned process on its own instead of
+# letting it inherit the grant of the Terminal that started it. Homebrew's
+# Python is unsigned on Intel, so there is no identity for a grant to attach
+# to: every connection to a LAN address is dropped, with no error the
+# application can log and no permission prompt. All three printer ports read
+# as unreachable while the subnet check passes (#3114).
+_CODESIGN = "/usr/bin/codesign"
+# Reading a local file's signature takes milliseconds, so this is a guard
+# rather than a budget -- and it is deliberately short. The support bundle
+# gives each printer 15s total (_PER_DIAGNOSTIC_TIMEOUT_SECONDS) and drops
+# the whole connection diagnostic on overrun, so a codesign that hangs (the
+# stub that offers to install the command line tools is the plausible way)
+# must not be able to cost the bundle the rest of its checks.
+_CODESIGN_TIMEOUT = 2.0
+
+
+def _base_interpreter_path() -> str:
+    """The interpreter macOS judges, as both the probe and the message see it.
+
+    ``sys._base_executable`` rather than ``sys.executable``: inside a venv the
+    latter is a symlink in the venv's own bin directory, and what macOS judges
+    is the real interpreter it resolves to. Resolved once, here, so the path
+    reported to the user is the same one whose signature was read.
+    """
+    return os.path.realpath(getattr(sys, "_base_executable", None) or sys.executable)
+
+
+def _interpreter_is_signed() -> bool | None:
+    """Does the interpreter Bambuddy runs under carry a code signature?
+
+    None when it cannot be told: no usable ``codesign`` because the Xcode
+    command line tools are absent, or the probe failed some other way. That
+    is deliberately not folded into False. The advice for "no identity" names
+    a repair that rewrites a file inside the user's Python installation, and
+    offering that on a guess is worse than giving the generic answer.
+
+    On an Apple Silicon Homebrew install the interpreter resolves to the
+    framework's ``bin/pythonX.Y`` (measured, inside and outside a venv alike)
+    -- not the ``Python.app`` stub, which is a separate binary in the same
+    framework. The reporter's TCC log names the same ``bin/pythonX.Y`` on
+    Intel.
+    """
+    executable = _base_interpreter_path()
+    if not executable:
+        return None
+    try:
+        result = subprocess.run(
+            [_CODESIGN, "-d", executable],
+            capture_output=True,
+            text=True,
+            timeout=_CODESIGN_TIMEOUT,
+        )
+    except Exception:
+        # Fail soft, as everywhere else in this module: a diagnostic that
+        # raises is worse than one that declines to answer.
+        logger.debug("codesign probe failed", exc_info=True)
+        return None
+    if result.returncode == 0:
+        return True
+    # codesign writes this to stderr and exits non-zero. It is the one
+    # outcome that separates "no identity at all" from "the probe never ran".
+    if "not signed at all" in result.stderr:
+        return False
+    return None
 
 
 
 
 async def run_connection_diagnostic(
 async def run_connection_diagnostic(
@@ -292,19 +451,67 @@ async def run_connection_diagnostic(
         )
         )
     )
     )
 
 
-    # --- Docker network mode ---
+    # --- macOS Local Network permission ---
+    # Appended on macOS only. Everywhere else there is nothing to say, and a
+    # permanently dimmed "skipped" row would be noise for the users who make
+    # up nearly all of them.
+    #
+    # Both outcomes are reported as warn rather than fail, and only when the
+    # control port is already unreachable -- so this can never be the check
+    # that turns an otherwise healthy result red. A printer that is simply
+    # switched off produces the same all-ports-dead pattern, which is why the
+    # signature probe, not the pattern, is what earns the specific advice.
+    if sys.platform == "darwin":
+        if mqtt_ok:
+            # The control port answered, so LAN access demonstrably works.
+            checks.append(DiagnosticCheck(id="macos_local_network", status="pass"))
+        else:
+            signed = await asyncio.to_thread(_interpreter_is_signed)
+            if signed is False:
+                checks.append(
+                    DiagnosticCheck(
+                        id="macos_local_network",
+                        status="warn",
+                        params={"reason": "unsigned", "executable": _base_interpreter_path()},
+                    )
+                )
+            else:
+                # Signed, or undeterminable. An ad-hoc signature -- which is
+                # what every arm64 binary carries, because the linker adds one
+                # -- identifies itself by a hash of the binary, so a Python
+                # upgrade presents macOS with a new application and leaves the
+                # old grant behind. That is repairable in System Settings,
+                # unlike the unsigned case, so point there instead.
+                checks.append(DiagnosticCheck(id="macos_local_network", status="warn", params={"reason": "permission"}))
+
+    # --- Container network mode ---
+    # Not Docker-only: Podman runs Bambuddy in exactly the same two shapes and
+    # its users were told "Not running in Docker", which reads as "you are on
+    # bare metal" and sent them looking for the problem somewhere else (#3092).
+    runtime = detect_container_runtime()
     network_mode: str | None = None
     network_mode: str | None = None
-    if is_running_in_docker():
-        network_mode = _detect_docker_network_mode()
+    if runtime is None:
+        checks.append(DiagnosticCheck(id="network_mode", status="skip"))
+    elif runtime not in OCI_RUNTIMES:
+        # An LXC/LXD system container is bridged onto the LAN like a small VM.
+        # There is no network mode to recommend, so don't imply there is one.
         checks.append(
         checks.append(
-            DiagnosticCheck(
-                id="network_mode",
-                status="pass" if network_mode == "host" else "warn",
-                params={"mode": network_mode},
-            )
+            DiagnosticCheck(id="network_mode", status="skip", params={"reason": "system_container", "runtime": runtime})
         )
         )
     else:
     else:
-        checks.append(DiagnosticCheck(id="network_mode", status="skip"))
+        network_mode = _detect_container_network_mode(runtime)
+        if network_mode is None:
+            checks.append(
+                DiagnosticCheck(id="network_mode", status="skip", params={"reason": "unknown", "runtime": runtime})
+            )
+        else:
+            checks.append(
+                DiagnosticCheck(
+                    id="network_mode",
+                    status="pass" if network_mode == "host" else "warn",
+                    params={"mode": network_mode, "runtime": runtime},
+                )
+            )
 
 
     # --- Subnet match ---
     # --- Subnet match ---
     # Skipped in bridge mode: the container IP is the bridge IP, not the host's,
     # Skipped in bridge mode: the container IP is the bridge IP, not the host's,
@@ -312,8 +519,9 @@ async def run_connection_diagnostic(
     if network_mode == "bridge":
     if network_mode == "bridge":
         checks.append(DiagnosticCheck(id="subnet", status="skip"))
         checks.append(DiagnosticCheck(id="subnet", status="skip"))
     else:
     else:
-        host_ip = _get_host_ip()
-        same = _same_subnet(ip_address, host_ip) if host_ip else None
+        host_ip = _host_source_ip(ip_address)
+        # Off the loop: resolving the prefix shells out to `ip -j addr show`.
+        same = await asyncio.to_thread(_same_subnet, ip_address, host_ip) if host_ip else None
         if same is None:
         if same is None:
             checks.append(DiagnosticCheck(id="subnet", status="skip"))
             checks.append(DiagnosticCheck(id="subnet", status="skip"))
         else:
         else:
@@ -393,7 +601,7 @@ async def run_connection_diagnostic(
     ):
     ):
         # The toggle is on, a card is in, the printer said the last print's file
         # The toggle is on, a card is in, the printer said the last print's file
         # is on internal storage — and a probe confirmed it really is out of
         # is on internal storage — and a probe confirmed it really is out of
-        # reach. That is what H2-series and P2S firmware does, and no setting
+        # reach. That is what H2-series, P2S and X2D firmware does, and no setting
         # here changes it (#2762 tracks reading that storage). A pass here would
         # here changes it (#2762 tracks reading that storage). A pass here would
         # be a lie; a fail would be unresolvable.
         # be a lie; a fail would be unresolvable.
         #
         #

+ 5 - 34
backend/app/services/printer_manager.py

@@ -15,6 +15,7 @@ from backend.app.services.bambu_mqtt import (
     PrinterState,
     PrinterState,
     get_stage_name,
     get_stage_name,
 )
 )
+from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.kprofile_lookup import build_slot_k_resolver
 from backend.app.utils.kprofile_lookup import build_slot_k_resolver
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -187,24 +188,6 @@ def has_stg_cur_idle_bug(model: str | None) -> bool:
     return model_upper in STG_CUR_IDLE_BUG_MODELS
     return model_upper in STG_CUR_IDLE_BUG_MODELS
 
 
 
 
-def is_bed_slinger(model: str | None) -> bool:
-    """Whether the printer's Z axis controls the *toolhead*, not the bed.
-
-    Bambu's A1 family (A1, A1 Mini; internal codes N1 / N2S) are open-frame
-    bed-slingers: the bed moves on Y, the toolhead moves on X+Z. On every
-    other current model (X1, P1, H2, H2C, H2D, H2S, P2S, ...) the bed moves
-    on Z and the toolhead is fixed in Z.
-
-    G-code direction is opposite on these two families. `G1 Z-10` reduces
-    the nozzle-bed gap on both, but on bed-on-Z machines it does so by
-    moving the BED up, while on bed-slingers it does so by moving the
-    TOOLHEAD down — which is what crashed the nozzle in #1334.
-    """
-    if not model:
-        return False
-    return model.strip().upper() in A1_MODELS
-
-
 # Minimum firmware versions for AMS drying support (confirmed via capture testing)
 # Minimum firmware versions for AMS drying support (confirmed via capture testing)
 # Keys are exact model names (upper-cased). Do NOT use substring matching — it would
 # Keys are exact model names (upper-cased). Do NOT use substring matching — it would
 # incorrectly gate X1E (matched by "X1") and H2D Pro (matched by "H2D").
 # incorrectly gate X1E (matched by "X1") and H2D Pro (matched by "H2D").
@@ -1417,22 +1400,10 @@ def printer_state_to_dict(
                         "exists": tray.get("exists"),
                         "exists": tray.get("exists"),
                     }
                     }
                 )
                 )
-            # Prefer humidity_raw (actual percentage) over humidity (index 1-5)
-            humidity_raw = ams_data.get("humidity_raw")
-            humidity_idx = ams_data.get("humidity")
-            humidity_value = None
-
-            if humidity_raw is not None:
-                try:
-                    humidity_value = int(humidity_raw)
-                except (ValueError, TypeError):
-                    pass  # Skip unparseable humidity; will try index fallback
-            # Fall back to index if no raw value (index is 1-5, not percentage)
-            if humidity_value is None and humidity_idx is not None:
-                try:
-                    humidity_value = int(humidity_idx)
-                except (ValueError, TypeError):
-                    pass  # Skip unparseable humidity index; humidity remains None
+            # Percentage only — the 1-5 index is inverted and must never stand
+            # in for one (#3140). See utils/ams_humidity.
+            humidity_pct = ams_humidity_percent(ams_data)
+            humidity_value = int(round(humidity_pct)) if humidity_pct is not None else None
 
 
             # AMS-HT has 1 tray, regular AMS has 4 trays
             # AMS-HT has 1 tray, regular AMS has 4 trays
             is_ams_ht = len(trays) == 1
             is_ams_ht = len(trays) == 1

+ 27 - 8
backend/app/services/slice_preview.py

@@ -14,10 +14,13 @@ we don't need to thread the user's profile triplet through here. That choice
 also protects the numbers — overriding the process preset drops the project's
 also protects the numbers — overriding the process preset drops the project's
 own support configuration, which loses whole slots from the answer.
 own support configuration, which loses whole slots from the answer.
 
 
-The one thing that can defeat those embedded settings is a custom G-code
-template written by a Studio newer than the sidecar, which fails to parse
-before any slice_info exists. That case gets one retry with the offending
-template blanked; see ``_blank_custom_gcode``.
+Two things can defeat those embedded settings. A custom G-code template
+written by a Studio newer than the sidecar fails to parse before any
+slice_info exists; that case gets one retry with the offending template
+blanked, see ``_blank_custom_gcode``. And Bambu Studio writes inherit/unset
+markers into ``project_settings.config`` that some slicer builds' range
+validator rejects outright, so the same sanitiser the real slice runs is
+applied here too, see ``sanitize_project_settings_sentinels``.
 
 
 Results are cached by ``(kind, source_id, plate_id, content_hash)`` so
 Results are cached by ``(kind, source_id, plate_id, content_hash)`` so
 repeat opens on the same plate are instant. LRU eviction keeps the cache
 repeat opens on the same plate are instant. LRU eviction keeps the cache
@@ -42,6 +45,7 @@ from backend.app.services.slicer_api import (
     SlicerApiError,
     SlicerApiError,
     SlicerApiService,
     SlicerApiService,
 )
 )
+from backend.app.utils.threemf_tools import sanitize_project_settings_sentinels
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -203,13 +207,17 @@ async def get_preview_filaments(
 
 
     Uses the file's embedded settings (``slice_without_profiles``) since the
     Uses the file's embedded settings (``slice_without_profiles``) since the
     slot mapping is a model property, independent of any user-picked profile
     slot mapping is a model property, independent of any user-picked profile
-    triplet. A slice killed by an unparsable custom G-code template is retried
-    once with that template blanked, still on the file's own settings.
+    triplet. Those settings are sentinel-sanitised first (#1201, #3030). A
+    slice killed by an unparsable custom G-code template is retried once with
+    that template blanked, still on the file's own settings.
 
 
     Returns ``None`` when the preview slice fails — the caller should fall
     Returns ``None`` when the preview slice fails — the caller should fall
     back to whatever heuristic it has (typically the project_filaments +
     back to whatever heuristic it has (typically the project_filaments +
     painted-face approach in ``threemf_tools``).
     painted-face approach in ``threemf_tools``).
     """
     """
+    # Hash the file as it was given to us, not as it is sent: the key
+    # identifies the source file, and sanitising is deterministic, so folding
+    # it in would only make two names for one thing.
     h = _content_hash(file_bytes)
     h = _content_hash(file_bytes)
     key: _PreviewCacheKey = (kind, source_id, plate_id, h)
     key: _PreviewCacheKey = (kind, source_id, plate_id, h)
     cached = _preview_cache.get(key)
     cached = _preview_cache.get(key)
@@ -231,6 +239,17 @@ async def get_preview_filaments(
         # while the slicer is visibly working.
         # while the slicer is visibly working.
         svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
         svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
 
 
+        # Same sanitiser the real slice runs (#1201, #3030). It matters more
+        # here, not less: this path slices on the file's own embedded
+        # settings, so there is no --load-settings pass that could supply a
+        # replacement for a field the CLI's range validator has already
+        # rejected. Without it a MakerWorld 3MF carrying Bambu's inherit
+        # markers fails before producing any slice_info, and the modal falls
+        # back to its painted-face heuristic for a file the slicer could have
+        # answered exactly. Applied before the G-code retry below so that
+        # retry inherits it rather than reintroducing the markers.
+        slice_bytes = sanitize_project_settings_sentinels(file_bytes)
+
         async def _slice(model_bytes: bytes):
         async def _slice(model_bytes: bytes):
             async with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
             async with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
                 return await svc.slice_without_profiles(
                 return await svc.slice_without_profiles(
@@ -242,7 +261,7 @@ async def get_preview_filaments(
                 )
                 )
 
 
         try:
         try:
-            result = await _slice(file_bytes)
+            result = await _slice(slice_bytes)
         except SlicerApiError as e:
         except SlicerApiError as e:
             # One retry, and only for a custom-G-code template the sidecar
             # One retry, and only for a custom-G-code template the sidecar
             # cannot parse — a file from a Studio newer than the sidecar. The
             # cannot parse — a file from a Studio newer than the sidecar. The
@@ -259,7 +278,7 @@ async def get_preview_filaments(
             retry_bytes = None
             retry_bytes = None
             option = _unparsable_gcode_option(str(e))
             option = _unparsable_gcode_option(str(e))
             if option is not None:
             if option is not None:
-                retry_bytes = _blank_custom_gcode(file_bytes, option)
+                retry_bytes = _blank_custom_gcode(slice_bytes, option)
             if retry_bytes is None:
             if retry_bytes is None:
                 logger.warning(
                 logger.warning(
                     "Preview slice failed for %s/%s plate %s: %s",
                     "Preview slice failed for %s/%s plate %s: %s",

+ 112 - 4
backend/app/services/slicer_filament_resolver.py

@@ -35,10 +35,12 @@ from __future__ import annotations
 
 
 import json
 import json
 import logging
 import logging
+import re
 
 
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.utils.filament_ids import (
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_FILAMENT_IDS,
@@ -49,6 +51,65 @@ from backend.app.utils.filament_types import is_material_name
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# Orca Cloud profile ids are UUIDs, the one preset reference in this codebase
+# with no letter prefix to key off. A spool stores the bare id (the spool form
+# persists ``preset.setting_id`` verbatim), so shape is all there is to go on.
+_ORCA_PROFILE_ID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
+
+
+async def _orca_filament_id(
+    db: AsyncSession,
+    current_user: User | None,
+    profile_id: str,
+) -> tuple[str, str | None, str | None]:
+    """Look up an Orca Cloud profile's own filament_id.
+
+    Returns ``(filament_id, name, filament_type)`` -- all empty/None when the
+    profile cannot be fetched or carries no id of its own, which leaves the
+    caller on its generic fallback.
+
+    Best-effort by construction: this runs inside spool assignment, not a user
+    request, so a missing pairing, a revoked token or a lapsed permission must
+    degrade to the fallback rather than fail the assignment. That is also why
+    ``clear_on_auth_failure=False`` -- Orca reports every refresh rejection with
+    one composite reason, so a background caller cannot tell a real revocation
+    from a lost rotation race and must not wipe a working pairing on it. The
+    route path hits the same failure in front of a user and clears there.
+    """
+    if current_user is not None and not current_user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
+        logger.debug("Orca filament lookup skipped for %r: caller lacks orca_cloud:auth", profile_id)
+        return ("", None, None)
+
+    svc = None
+    try:
+        from backend.app.api.routes.orca_cloud import _build_authenticated_service
+
+        svc = await _build_authenticated_service(db, current_user, clear_on_auth_failure=False)
+        profile = await svc.get_profile(profile_id)
+    except Exception as e:
+        logger.debug("Orca filament lookup failed for %r: %s", profile_id, e)
+        return ("", None, None)
+    finally:
+        # A raise in `finally` escapes the `except` above, so guard it: closing
+        # an httpx client must never be what fails a spool assignment.
+        if svc is not None:
+            try:
+                await svc.close()
+            except Exception as e:  # noqa: BLE001 - close() is best-effort
+                logger.debug("Orca client close failed after lookup of %r: %s", profile_id, e)
+
+    content = profile.get("content") if isinstance(profile, dict) else None
+    if not isinstance(content, dict):
+        return ("", None, None)
+    raw_fid = content.get("filament_id")
+    filament_id = raw_fid.strip() if isinstance(raw_fid, str) else ""
+    name = profile.get("name") if isinstance(profile, dict) else None
+    return (
+        filament_id,
+        name if isinstance(name, str) and name else None,
+        _preset_filament_type(content.get("filament_type")),
+    )
+
 
 
 def _preset_filament_type(raw: object) -> str | None:
 def _preset_filament_type(raw: object) -> str | None:
     """Read a slicer preset's ``filament_type`` field.
     """Read a slicer preset's ``filament_type`` field.
@@ -129,7 +190,26 @@ async def resolve_slicer_filament(
     # All three need a cloud-detail lookup to extract the underlying
     # All three need a cloud-detail lookup to extract the underlying
     # filament_id; without it the raw cloud id ends up in tray_info_idx
     # filament_id; without it the raw cloud id ends up in tray_info_idx
     # and the printer's calibration table can't resolve it.
     # and the printer's calibration table can't resolve it.
-    if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
+    # Source order is Orca Cloud, Bambu Cloud, local import, generic fallback.
+    # Orca goes first because its ids are the only ones identified by shape
+    # rather than prefix -- and because, before #3003, a UUID fell through every
+    # branch below into ``normalize_slicer_filament``, which passes anything it
+    # does not recognise straight through. A 36-character UUID then went into
+    # tray_info_idx, an 8-character field, and the slot ended up pointing at the
+    # first 8 characters of a UUID: the same failure the PFUS guard at the
+    # bottom of this function exists for.
+    if _ORCA_PROFILE_ID.fullmatch(base_sf):
+        tray_info_idx, orca_name, orca_type = await _orca_filament_id(db, current_user, base_sf)
+        if orca_type:
+            type_override = orca_type
+        if orca_name:
+            sub_brand_override = orca_name.split("@")[0].strip()
+        # setting_id is left empty here: the UUID is what the slicer cannot
+        # resolve, and unlike a PFUS there is no cloud id form it accepts
+        # instead. All three callers then derive one from the filament_id
+        # (`filament_id_to_setting_id`), which is what keeps the slot from
+        # going out half configured -- the same path a local import takes.
+    elif base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
         setting_id = base_sf
         setting_id = base_sf
         try:
         try:
             from backend.app.api.routes.cloud import build_authenticated_cloud
             from backend.app.api.routes.cloud import build_authenticated_cloud
@@ -147,8 +227,30 @@ async def resolve_slicer_filament(
                     type_override = _preset_filament_type(
                     type_override = _preset_filament_type(
                         (cloud_setting if isinstance(cloud_setting, dict) else detail).get("filament_type")
                         (cloud_setting if isinstance(cloud_setting, dict) else detail).get("filament_type")
                     )
                     )
-                    if detail.get("filament_id"):
-                        tray_info_idx = detail["filament_id"]
+                    # A custom preset's OWN filament_id is the only thing that
+                    # gets it into an AMS slot as itself: the printer stores
+                    # that id, the slicer matches its presets against it, and
+                    # the 8-character field fits it exactly ("P" + 7 hex).
+                    #
+                    # Bambu Cloud normally returns it on the envelope, which is
+                    # what the captures in #1053 show for a Studio-created
+                    # preset (filament_id: "Pbd31b30"). The `setting` fallback
+                    # here is belt-and-braces for a response that carries it in
+                    # the preset JSON instead, the same spread `filament_type`
+                    # above has to handle -- no captured response has needed it
+                    # yet, and it costs a dict lookup to be ready for one.
+                    #
+                    # An Orca-created preset has no filament_id anywhere: the
+                    # envelope says null and `setting` is a delta from the base
+                    # (#1053 again). Those legitimately fall to base_id below
+                    # and reach the slicer as the profile they inherit from --
+                    # an OrcaSlicer preset-format gap, filed upstream as
+                    # OrcaSlicer PR #13315, not something resolvable here.
+                    own_filament_id = detail.get("filament_id") or (
+                        cloud_setting.get("filament_id") if isinstance(cloud_setting, dict) else None
+                    )
+                    if own_filament_id:
+                        tray_info_idx = own_filament_id
                         cloud_name = detail.get("name", "")
                         cloud_name = detail.get("name", "")
                         if cloud_name:
                         if cloud_name:
                             sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
                             sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
@@ -245,10 +347,16 @@ async def resolve_slicer_filament(
     #      the original assign.
     #      the original assign.
     #   3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
     #   3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
     #      "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
     #      "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
+    #   4. Orca Cloud profile UUIDs, when the branch above could not reach the
+    #      profile to trade one for its filament_id (#3003). Worst of the four
+    #      at 36 characters against an 8-character field.
     # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
     # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
     # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
     # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
     if tray_info_idx and (
     if tray_info_idx and (
-        is_material_name(tray_info_idx) or tray_info_idx.startswith("PFUS") or tray_info_idx.startswith("PFCN")
+        is_material_name(tray_info_idx)
+        or tray_info_idx.startswith("PFUS")
+        or tray_info_idx.startswith("PFCN")
+        or _ORCA_PROFILE_ID.fullmatch(tray_info_idx)
     ):
     ):
         tray_info_idx = ""
         tray_info_idx = ""
         # Preserve setting_id when it's still a valid slicer reference
         # Preserve setting_id when it's still a valid slicer reference

+ 40 - 0
backend/app/services/tag_conflict.py

@@ -0,0 +1,40 @@
+"""The one answer both inventory modes give when a tag is already taken.
+
+Linking an RFID tag lives in two routes -- ``inventory.py`` for the built-in
+inventory and ``spoolman_inventory.py`` for Spoolman mode -- and they used to
+refuse a duplicate with two different sentences, only one of which named the
+spool holding the tag (#3110). A client cannot act on prose, so both now raise
+the structured detail built here.
+"""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from fastapi import HTTPException
+
+# Which identifier collided: separate columns on a built-in spool, separate
+# lengths inside Spoolman's ``extra.tag``. A client that offers to move the tag
+# needs to know which of the two it is moving.
+TagField = Literal["tag_uid", "tray_uuid"]
+
+_FIELD_LABELS: dict[TagField, str] = {"tag_uid": "Tag UID", "tray_uuid": "Tray UUID"}
+
+
+def tag_already_linked(field: TagField, holder_id: int) -> HTTPException:
+    """409 naming the active spool that already carries this tag.
+
+    The frontend renders the user-facing message via i18n on ``code``;
+    ``message`` is an English fallback for non-UI clients (curl / scripts).
+    ``holder_id`` is what lets a caller offer to move the tag rather than only
+    report that it is taken.
+    """
+    return HTTPException(
+        status_code=409,
+        detail={
+            "code": "tag_already_linked",
+            "message": f"{_FIELD_LABELS[field]} is already linked to spool {holder_id}",
+            "spool_id": holder_id,
+            "field": field,
+        },
+    )

+ 58 - 12
backend/app/services/virtual_printer/certificate.py

@@ -1,7 +1,8 @@
 """TLS certificate generation for virtual printer services.
 """TLS certificate generation for virtual printer services.
 
 
-Generates certificates that mimic real Bambu printer certificate format:
-- CA certificate mimics "BBL CA" from "BBL Technologies Co., Ltd"
+Generates the certificate chain a slicer accepts in place of a real printer's:
+- CA certificate with CN = "Virtual Printer CA <id>", unique to the install
+  that generated it (a CA generated before that carries the bare name)
 - Printer certificate has CN = serial number, signed by the CA
 - Printer certificate has CN = serial number, signed by the CA
 
 
 The CA certificate is persistent and only regenerated if missing or expired.
 The CA certificate is persistent and only regenerated if missing or expired.
@@ -27,6 +28,11 @@ DEFAULT_SERIAL = "00M09A391800001"
 # Minimum days remaining before CA is considered expired and needs regeneration
 # Minimum days remaining before CA is considered expired and needs regeneration
 CA_EXPIRY_THRESHOLD_DAYS = 30
 CA_EXPIRY_THRESHOLD_DAYS = 30
 
 
+# Common-name prefix of the generated CA. What follows it is derived from the
+# CA's own public key, so two installs never share a Subject DN -- see
+# ``_generate_ca_certificate`` for why that matters.
+CA_COMMON_NAME_PREFIX = "Virtual Printer CA"
+
 
 
 def _get_local_ip() -> str:
 def _get_local_ip() -> str:
     """Get the local IP address."""
     """Get the local IP address."""
@@ -43,8 +49,10 @@ def _get_local_ip() -> str:
 class CertificateService:
 class CertificateService:
     """Generate and manage TLS certificates for virtual printer.
     """Generate and manage TLS certificates for virtual printer.
 
 
-    Creates a certificate chain mimicking real Bambu printers:
-    - Root CA with CN="BBL CA", O="BBL Technologies Co., Ltd", C="CN"
+    Creates a certificate chain a slicer accepts in place of a real
+    printer's:
+    - Root CA with CN="Virtual Printer CA <id>", unique to the install that
+      generated it (an older CA carries the bare name and is kept as it is)
     - Printer cert with CN=serial_number, signed by the CA
     - Printer cert with CN=serial_number, signed by the CA
     """
     """
 
 
@@ -90,9 +98,10 @@ class CertificateService:
         is broken even though both files exist on disk. ``ensure_certificates``
         is broken even though both files exist on disk. ``ensure_certificates``
         uses this to decide whether to regenerate.
         uses this to decide whether to regenerate.
 
 
-        Uses real signature verification — Bambuddy's auto-generated CAs all
-        share the same Subject DN ("Virtual Printer CA"), so a DN-only compare
-        would incorrectly return True even after rotation.
+        Uses real signature verification — every CA generated before the
+        common name carried a per-install suffix is literally
+        "CN=Virtual Printer CA", so on those installs a DN-only compare would
+        incorrectly return True even after rotation.
         """
         """
         try:
         try:
             if not self.ca_cert_path.exists():
             if not self.ca_cert_path.exists():
@@ -213,10 +222,25 @@ class CertificateService:
             key_size=2048,
             key_size=2048,
         )
         )
 
 
-        # Use a generic CA name - NOT BBL to avoid being rejected as fake
+        # Use a generic CA name - NOT BBL to avoid being rejected as fake.
+        #
+        # The name carries a per-install suffix taken from this CA's own key
+        # identifier. A slicer trust store is a flat list of certificates and
+        # OpenSSL looks an issuer up by Subject DN: it takes the first CA whose
+        # DN matches and fails the chain if that one did not sign the
+        # certificate, rather than trying the next match. So while every
+        # install signed as plain "CN=Virtual Printer CA", a user who imported
+        # the CAs of two Bambuddy instances broke one of them — each worked on
+        # its own, together whichever landed second in the file lost, with the
+        # same generic connection error an unimported CA gives (#3014).
+        # Distinct DNs mean both are found and both verify.
+        ca_skid = x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key())
         ca_name = x509.Name(
         ca_name = x509.Name(
             [
             [
-                x509.NameAttribute(NameOID.COMMON_NAME, "Virtual Printer CA"),
+                x509.NameAttribute(
+                    NameOID.COMMON_NAME,
+                    f"{CA_COMMON_NAME_PREFIX} {ca_skid.digest.hex()[:8].upper()}",
+                ),
             ]
             ]
         )
         )
 
 
@@ -248,6 +272,7 @@ class CertificateService:
                 ),
                 ),
                 critical=True,
                 critical=True,
             )
             )
+            .add_extension(ca_skid, critical=False)
             .sign(ca_key, hashes.SHA256())
             .sign(ca_key, hashes.SHA256())
         )
         )
 
 
@@ -313,12 +338,23 @@ class CertificateService:
         # Issuer is the CA
         # Issuer is the CA
         issuer = ca_cert.subject
         issuer = ca_cert.subject
 
 
+        # Key identifiers, but only when the CA carries one to point at. A CA
+        # generated before the per-install common name has no
+        # SubjectKeyIdentifier, and a leaf signed by it keeps exactly the shape
+        # it has today rather than naming an identifier its issuer does not
+        # advertise — those installs keep working with the CA they imported
+        # long ago, untouched.
+        try:
+            ca_skid = ca_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
+        except x509.ExtensionNotFound:
+            ca_skid = None
+
         now = datetime.now(timezone.utc)
         now = datetime.now(timezone.utc)
         local_ip = _get_local_ip()
         local_ip = _get_local_ip()
         logger.info("Generating printer certificate with CN=%s, local IP: %s", self.serial, local_ip)
         logger.info("Generating printer certificate with CN=%s, local IP: %s", self.serial, local_ip)
 
 
         # Build printer certificate signed by CA
         # Build printer certificate signed by CA
-        printer_cert = (
+        printer_cert_builder = (
             x509.CertificateBuilder()
             x509.CertificateBuilder()
             .subject_name(printer_subject)
             .subject_name(printer_subject)
             .issuer_name(issuer)
             .issuer_name(issuer)
@@ -357,9 +393,19 @@ class CertificateService:
                 ),
                 ),
                 critical=True,
                 critical=True,
             )
             )
-            .sign(ca_key, hashes.SHA256())  # Signed by CA, not self-signed
         )
         )
 
 
+        if ca_skid is not None:
+            printer_cert_builder = printer_cert_builder.add_extension(
+                x509.SubjectKeyIdentifier.from_public_key(printer_key.public_key()),
+                critical=False,
+            ).add_extension(
+                x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ca_skid),
+                critical=False,
+            )
+
+        printer_cert = printer_cert_builder.sign(ca_key, hashes.SHA256())  # Signed by CA, not self-signed
+
         # Write printer private key
         # Write printer private key
         self.key_path.write_bytes(
         self.key_path.write_bytes(
             printer_key.private_bytes(
             printer_key.private_bytes(
@@ -380,7 +426,7 @@ class CertificateService:
         self.cert_path.write_bytes(cert_chain)
         self.cert_path.write_bytes(cert_chain)
 
 
         logger.info("Generated certificate chain at %s", self.cert_dir)
         logger.info("Generated certificate chain at %s", self.cert_dir)
-        logger.info("  CA: CN=Virtual Printer CA")
+        logger.info("  CA: %s", ca_cert.subject.rfc4514_string())
         logger.info("  Printer: CN=%s", self.serial)
         logger.info("  Printer: CN=%s", self.serial)
         return self.cert_path, self.key_path
         return self.cert_path, self.key_path
 
 

+ 40 - 0
backend/app/utils/ams_humidity.py

@@ -0,0 +1,40 @@
+"""Shared reading of an AMS unit's humidity.
+
+Bambu sends two humidity fields and they are not the same quantity.
+``humidity_raw`` is relative humidity in percent. ``humidity`` is a 1-5 drop
+index, and it runs the other way: OpenBambuAPI's push_info sample carries both
+in one line -- ``ams0 temp:18.4;humidity:30%;humidity_idx:4`` -- so a high index
+means dry where a high percentage means wet.
+
+Falling back from one to the other therefore does not degrade, it inverts.
+Index 2 rendered as "2%" reads as the driest a unit can be while the unit is in
+fact the second-wettest of the five steps, and no index can ever exceed a
+percentage threshold, so the humidity alarm and auto-drying silently never fire
+for such a unit (#3140). A unit that reports no percentage has no percentage:
+this returns ``None``, which every caller already treats as "no reading" -- the
+card hides the indicator, the alarm and auto-drying skip the unit, and the
+history chart leaves a gap.
+
+Kept as a leaf module like ``ams_drying``: nothing here imports from the app.
+"""
+
+from collections.abc import Mapping
+from typing import Any
+
+
+def ams_humidity_percent(ams_data: Any) -> float | None:
+    """Relative humidity in percent for one AMS unit, or ``None``.
+
+    ``None`` covers every case where the unit did not report a usable
+    percentage, including the units that send only the 1-5 index -- which is
+    deliberately never converted. See the module docstring.
+    """
+    if not isinstance(ams_data, Mapping):
+        return None
+    raw = ams_data.get("humidity_raw")
+    if raw is None:
+        return None
+    try:
+        return float(raw)
+    except (TypeError, ValueError):
+        return None  # Unparseable reading — not a licence to use the index

+ 9 - 6
backend/app/utils/fts_routing.py

@@ -1,11 +1,14 @@
 """Which nozzle an AMS slot feeds, with or without a Filament Track Switch.
 """Which nozzle an AMS slot feeds, with or without a Filament Track Switch.
 
 
-K-profiles are per-nozzle, and the printer's calibration tables are numbered
-per-nozzle too: ``cali_idx: 16`` means "entry 16 of whichever nozzle feeds this
-tray". Without a switch that is unambiguous, because each AMS is wired to one
-extruder and says so in its ``info`` bits. With a switch installed every AMS
-reports 0xE instead and is bound to a switch *inlet*, so the answer has to come
-from the inlet binding.
+K-profiles are per-nozzle: a calibration run belongs to the hotend it ran on,
+and ``cali_idx: 16`` can name a different profile on each. (It need not — one
+profile can also be what both extruders' slots point at, which is why the K
+lookup in ``kprofile_lookup`` treats the extruder as a preference rather than a
+filter — but the routing question below is the same either way.) Without a
+switch the answer is unambiguous, because each AMS is wired to one extruder and
+says so in its ``info`` bits. With a switch installed every AMS reports 0xE
+instead and is bound to a switch *inlet*, so the answer has to come from the
+inlet binding.
 
 
 Every caller that resolves a slot to an extruder should go through
 Every caller that resolves a slot to an extruder should go through
 ``slot_extruder`` here. Three separate copies of that logic used to end in
 ``slot_extruder`` here. Three separate copies of that logic used to end in

+ 65 - 19
backend/app/utils/kprofile_lookup.py

@@ -2,21 +2,41 @@
 
 
 H2-series trays carry no ``k`` field of their own — only ``cali_idx`` — so the
 H2-series trays carry no ``k`` field of their own — only ``cali_idx`` — so the
 K value on the AMS slot card (#2854) is looked up from the printer's
 K value on the AMS slot card (#2854) is looked up from the printer's
-calibration table in ``state.kprofiles``. That table is not flat: the printer
-numbers it **per nozzle**, so entry 16 exists under every nozzle it holds
-profiles for and means a different profile on each.
+calibration table in ``state.kprofiles``.
 
 
-``state.kprofiles`` is the union across nozzle diameters (see
-``BambuMQTTClient._store_kprofiles``), which is what the assign paths need but
-makes ``cali_idx`` alone ambiguous. Resolution here is therefore:
+That table is not a clean per-nozzle numbering, and treating it as one is what
+blanked every slot on a second AMS (#3044). Both of these happen:
 
 
-1. the slot's own extruder, which separates the two nozzles of a dual-nozzle
-   machine outright;
-2. failing that, the diameters currently installed, which separates a live
-   table from one left behind by a nozzle that has since been swapped out.
+* Two profiles can share a ``cali_idx`` and differ by extruder — measured on
+  the maintainer's H2C, where one spool read 0.018 on the left nozzle and
+  0.020 on the right. Resolving on ``cali_idx`` alone showed the wrong one.
+* One profile can be what *both* extruders' slots point at. In the #3044
+  capture an X2D's B1 and B3 carried exactly the K values of A4 and A1 — the
+  same entries, tagged with one extruder. Demanding an extruder match left
+  every slot on the right-hand AMS blank.
 
 
-If both fail to single out one profile the answer is ``None``. A blank space on
-the card is a smaller error than confidently printing the other nozzle's number.
+The two are told apart by whether the table distinguishes extruders *at all*:
+
+1. a profile filed under the slot's own extruder wins outright;
+2. if the slot's extruder appears nowhere in the table, its tagging carries no
+   information about this slot, so match on ``cali_idx`` alone — taking the
+   answer only when the candidates agree on one K value, with the diameters
+   currently installed as the tie-break (which separates a live table from one
+   left behind by a nozzle that has since been swapped out).
+
+The condition on step 2 is what keeps the H2C case fixed. There extruder 0 does
+hold profiles, so a right-hand slot pointing at an index only the left hotend
+has is a real miss — the index means entry 16 *of the right nozzle's table*,
+and the left's entry 16 is a different profile. Falling back there is how the
+wrong K got shown in the first place.
+
+BambuStudio is looser still: ``AMSItem.cpp`` fills the same card through
+``CalibUtils::get_pa_k_n_value_by_cali_idx``, which scans the whole history for
+a matching ``cali_idx`` and takes the first hit regardless of nozzle.
+
+If neither step singles out one value the answer is ``None``. A blank space on
+the card is a smaller error than confidently printing the other nozzle's
+number.
 """
 """
 
 
 from collections.abc import Callable
 from collections.abc import Callable
@@ -34,6 +54,9 @@ def build_slot_k_resolver(state) -> Callable[[int | None, int, int], float | Non
     # detects the ambiguity: more than one entry means two nozzles' tables both
     # detects the ambiguity: more than one entry means two nozzles' tables both
     # claim this index on this extruder.
     # claim this index on this extruder.
     table: dict[tuple[int, int], dict[str, float]] = {}
     table: dict[tuple[int, int], dict[str, float]] = {}
+    # cali_idx -> [(nozzle_diameter, k)], every extruder together. The fallback
+    # for an index no profile claims on the slot's own extruder.
+    shared: dict[int, list[tuple[str, float]]] = {}
     for kp in getattr(state, "kprofiles", None) or []:
     for kp in getattr(state, "kprofiles", None) or []:
         if kp.slot_id is None or not kp.k_value:
         if kp.slot_id is None or not kp.k_value:
             continue
             continue
@@ -45,22 +68,45 @@ def build_slot_k_resolver(state) -> Callable[[int | None, int, int], float | Non
             extruder = int(kp.extruder_id or 0)
             extruder = int(kp.extruder_id or 0)
         except (ValueError, TypeError):
         except (ValueError, TypeError):
             extruder = 0
             extruder = 0
-        table.setdefault((extruder, kp.slot_id), {})[str(kp.nozzle_diameter or "")] = k_value
+        nozzle = str(kp.nozzle_diameter or "")
+        table.setdefault((extruder, kp.slot_id), {})[nozzle] = k_value
+        shared.setdefault(kp.slot_id, []).append((nozzle, k_value))
+
+    # Which extruders the table names at all. An extruder missing from this is
+    # one the printer is not filing profiles under, which is what makes the
+    # cali_idx-only fallback safe for it.
+    extruders_filed = {extruder for extruder, _ in table}
 
 
     installed = {str(n.nozzle_diameter) for n in (getattr(state, "nozzles", None) or []) if n.nozzle_diameter}
     installed = {str(n.nozzle_diameter) for n in (getattr(state, "nozzles", None) or []) if n.nozzle_diameter}
 
 
+    def _agreed(candidates: list[tuple[str, float]]) -> float | None:
+        """The one K these candidates describe, or None if they disagree.
+
+        Values rather than entries: two nozzles listing the same number is not
+        an ambiguity, it is the shared profile the fallback exists for.
+        """
+        values = {k for _, k in candidates}
+        if len(values) == 1:
+            return values.pop()
+        live = {k for nozzle, k in candidates if nozzle in installed}
+        return live.pop() if len(live) == 1 else None
+
     def resolve(cali_idx: int | None, ams_id: int, tray_id: int) -> float | None:
     def resolve(cali_idx: int | None, ams_id: int, tray_id: int) -> float | None:
         if cali_idx is None:
         if cali_idx is None:
             return None
             return None
         extruder = slot_extruder(ams_id, tray_id, state.ams_extruder_map, state.ams_switch_inlet)
         extruder = slot_extruder(ams_id, tray_id, state.ams_extruder_map, state.ams_switch_inlet)
         # Single-nozzle printers report everything under extruder 0, and that
         # Single-nozzle printers report everything under extruder 0, and that
         # is also the right default when the routing is simply unknown.
         # is also the right default when the routing is simply unknown.
-        by_nozzle = table.get((extruder if extruder is not None else 0, cali_idx))
-        if not by_nozzle:
+        own = extruder if extruder is not None else 0
+        by_nozzle = table.get((own, cali_idx))
+        if by_nozzle:
+            if len(by_nozzle) == 1:
+                return next(iter(by_nozzle.values()))
+            live = [k for nozzle, k in by_nozzle.items() if nozzle in installed]
+            return live[0] if len(live) == 1 else None
+        if own in extruders_filed:
             return None
             return None
-        if len(by_nozzle) == 1:
-            return next(iter(by_nozzle.values()))
-        live = [k for nozzle, k in by_nozzle.items() if nozzle in installed]
-        return live[0] if len(live) == 1 else None
+        candidates = shared.get(cali_idx)
+        return _agreed(candidates) if candidates else None
 
 
     return resolve
     return resolve

+ 96 - 0
backend/app/utils/paho_teardown.py

@@ -0,0 +1,96 @@
+"""Letting go of a paho MQTT client without waiting for its network thread.
+
+`Client.loop_stop()` is two statements: set `_thread_terminate`, then `join()`
+the network thread with no timeout. That thread only reads the flag between
+iterations of `loop_forever`, so it cannot read it while parked inside
+`reconnect()` -> `_ssl_wrap_socket()` -> `do_handshake()`. paho gives that
+handshake the connection's keepalive as its socket timeout -- 30s for a printer
+-- and a socket timeout is per operation, renewed by every byte the peer sends.
+A broker that still answers on its port but never finishes the handshake
+therefore holds the join open for as long as it keeps trickling; a silent one
+still holds it 30s.
+
+Whoever called `loop_stop()` waits that out, and in Bambuddy that caller is the
+asyncio thread. #3068: a printer 38 hours offline, still answering on 8883, was
+picked up by the connection watchdog exactly as intended; the rebuild ended in
+that join and the process stopped serving HTTP -- UI, API and health check --
+while staying alive, so the container's `restart: unless-stopped` never fired.
+#1445 was the same join reached from the add-printer probe.
+"""
+
+import logging
+import threading
+import time
+
+logger = logging.getLogger(__name__)
+
+# How long a retirement may take before it is worth a line in the support
+# bundle. A healthy paho thread exits in well under a second.
+_RETIRE_SLOW_SECONDS = 5.0
+
+# The callbacks Bambuddy's three MQTT services set between them. Anything else
+# paho offers is already None because nobody here assigns it.
+_CALLBACKS = ("on_connect", "on_disconnect", "on_subscribe", "on_message")
+
+
+def retire_paho_client(client, label: str) -> None:
+    """Shut *client* down on a thread of its own and return immediately.
+
+    *label* names the connection in logs and in the retirement thread's name,
+    which is where a thread dump from the next stuck one will be read.
+
+    Two things happen inline rather than on that thread:
+
+    - The callbacks are cleared here. Blocking until the network thread was
+      gone is what used to guarantee a client we had let go of could no longer
+      touch our state; with the teardown detached, a zombie that finishes its
+      handshake would auto-reconnect and report itself connected behind its
+      replacement's back.
+    - Nothing else -- not even `disconnect()`, which is cheap enough to run
+      here (it queues a packet and returns) but would be one more thing
+      between the caller and its return for no gain, since the thread starts
+      within microseconds. It still happens and still matters: it is what
+      stops paho's auto-reconnect, and with it the chance of an unacked
+      `project_file` replaying onto a revived session (#1136).
+    """
+    for attr in _CALLBACKS:
+        try:
+            setattr(client, attr, None)
+        except Exception:  # pragma: no cover - paho always allows this
+            pass
+
+    def _teardown() -> None:
+        started = time.monotonic()
+        try:
+            client.disconnect()
+        except Exception:
+            pass
+        try:
+            client.loop_stop()
+        except Exception:
+            pass
+        waited = time.monotonic() - started
+        if waited >= _RETIRE_SLOW_SECONDS:
+            # The stall that used to be the event loop's. Worth saying out
+            # loud: it means this connection is wedged somewhere paho cannot
+            # interrupt, and the next report of it should not have to be
+            # diagnosed from a thread dump again.
+            logger.warning(
+                "[%s] Retiring the old MQTT client took %.0fs (paho's network thread would "
+                "not stop). The connection was replaced anyway.",
+                label,
+                waited,
+            )
+
+    try:
+        threading.Thread(target=_teardown, name=f"mqtt-retire-{label}", daemon=True).start()
+    except RuntimeError as e:
+        # Out of threads entirely, which means the process has larger problems.
+        # Send the DISCONNECT inline anyway -- it is what keeps the abandoned
+        # session from reconnecting and replaying (#1136) -- and leave the
+        # network thread to paho.
+        logger.error("[%s] Could not start the MQTT teardown thread: %s", label, e)
+        try:
+            client.disconnect()
+        except Exception:
+            pass

+ 122 - 0
backend/app/utils/threemf_tools.py

@@ -1767,3 +1767,125 @@ def extract_plate_extruder_set_from_3mf(zf: zipfile.ZipFile, plate_id: int) -> s
                     used.update(_scan_paint(path))
                     used.update(_scan_paint(path))
         break
         break
     return used
     return used
+
+
+# Keys in ``Metadata/project_settings.config`` that Bambu Studio writes an
+# "inherit / unset" marker into, mapped to the marker it uses for that key.
+# The slicer CLI's ``StaticPrintConfig`` validator runs against the embedded
+# settings *before* ``--load-settings`` overrides apply, so a marker the CLI's
+# own range check rejects makes it exit non-zero before our profile triplet is
+# ever consulted.
+#
+# There are two markers because there are two conventions, and which one a
+# given CLI rejects depends on the build:
+#
+#   "-1" -- inherit from the parent process preset (#1201, MakerWorld P2S
+#   3MFs). ``raft_first_layer_expansion`` and ``tree_support_wall_count`` are
+#   min 0 in every OrcaSlicer to date, so those still fail on the current
+#   sidecar; ``prime_tower_brim_width`` gained min -1 in Orca 2.4.2 and now
+#   passes there, but not on older builds.
+#
+#   "0" -- "use the active object/part filament", the default Bambu Studio
+#   writes for the three feature-filament indices (#3030). Bambu Studio and
+#   OrcaSlicer 2.4.0+ both define these min 0, so 0 is legal there; OrcaSlicer
+#   2.3.x and earlier still used the 1-based scheme (min 1, default 1) and
+#   reject it with ``0 not in range [1.000000,...]``. Sidecar images are
+#   version-tagged, so an install can be pinned to one of those.
+#
+# Removing the key rather than rewriting it is what makes this safe on every
+# build: the CLI then falls back to its own compiled default, which is 0 on
+# the builds where 0 was legal (so nothing changes) and 1 on the older ones,
+# which is what "the active filament" means under that scheme.
+#
+# Allowlisted (rather than "strip every marker-shaped value") because some
+# fields legitimately take the marker value -- z_offset, translations, and any
+# feature index a user really did set to a first filament -- and a blanket
+# strip would silently corrupt those.
+#
+# Add new entries as reports surface: the slicer names the offending field
+# directly, e.g. ``<field>: <value> not in range [...]``.
+PROJECT_SETTINGS_SENTINELS: dict[str, str] = {
+    # Reported in #1201 (MakerWorld P2S 3MFs).
+    "raft_first_layer_expansion": "-1",
+    "tree_support_wall_count": "-1",
+    # Known sentinel case from earlier reports, cited in #1201.
+    "prime_tower_brim_width": "-1",
+    # Reported in #3030 (MakerWorld 3MF, OrcaSlicer sidecar).
+    "wall_filament": "0",
+    "sparse_infill_filament": "0",
+    "solid_infill_filament": "0",
+}
+
+PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
+
+
+def _is_sentinel(value: object, sentinel: str) -> bool:
+    """Does ``value`` carry ``sentinel``, whether stored as text or a number?
+
+    Bambu Studio writes every ``project_settings.config`` value as a string,
+    but a 3MF that has been round-tripped through another tool can carry the
+    same field as a JSON number. ``bool`` is excluded explicitly: it is an
+    ``int`` subclass in Python, and ``str(False)`` would otherwise never match
+    anyway -- the exclusion is there so a future numeric sentinel like ``0``
+    cannot be matched by ``False``.
+    """
+    if isinstance(value, bool):
+        return False
+    if isinstance(value, (str, int)):
+        return str(value) == sentinel
+    return False
+
+
+def sanitize_project_settings_sentinels(zip_bytes: bytes) -> bytes:
+    """Strip inherit/unset sentinels from a 3MF's ``project_settings.config``
+    so the slicer CLI's range validator accepts the file (#1201, #3030).
+
+    Removes only allowlisted keys (see ``PROJECT_SETTINGS_SENTINELS``) and only
+    when the value is exactly that key's sentinel. The rest of the config --
+    and every other entry in the zip -- is preserved byte-for-byte. Unlike a
+    whole-file strip this leaves ``StaticPrintConfig`` initialisation intact:
+    the file is still present, still parses, and the slicer falls back to the
+    supplied ``--load-settings`` value, or to its own default, for the removed
+    key.
+
+    Returns the original bytes unchanged when no sanitisation is needed (input
+    isn't a valid zip, no ``project_settings.config``, no allowlisted sentinels
+    present, or any other parse failure) so the caller can pass the result on
+    without further checks.
+    """
+    from io import BytesIO
+
+    try:
+        with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zin:
+            if PROJECT_SETTINGS_PATH not in zin.namelist():
+                return zip_bytes
+            try:
+                config = json.loads(zin.read(PROJECT_SETTINGS_PATH).decode("utf-8"))
+            except (json.JSONDecodeError, UnicodeDecodeError):
+                return zip_bytes
+            if not isinstance(config, dict):
+                return zip_bytes
+            removed = {
+                key: sentinel
+                for key, sentinel in PROJECT_SETTINGS_SENTINELS.items()
+                if _is_sentinel(config.get(key), sentinel)
+            }
+            if not removed:
+                return zip_bytes
+            for key in removed:
+                config.pop(key, None)
+            patched = json.dumps(config)
+            logger.info(
+                "3MF sanitiser: removed inherit sentinels %s - slicer will use its defaults for those keys",
+                sorted(f"{key}={sentinel}" for key, sentinel in removed.items()),
+            )
+            dst = BytesIO()
+            with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
+                for item in zin.infolist():
+                    if item.filename == PROJECT_SETTINGS_PATH:
+                        zout.writestr(item, patched)
+                    else:
+                        zout.writestr(item, zin.read(item.filename))
+            return dst.getvalue()
+    except (zipfile.BadZipFile, OSError):
+        return zip_bytes

+ 36 - 0
backend/tests/_fixtures/external_camera.py

@@ -0,0 +1,36 @@
+"""Stand-ins for the ffmpeg subprocess the external-camera paths spawn.
+
+Both RTSP paths in ``backend.app.services.external_camera`` build an argv and
+hand it to ``asyncio.create_subprocess_exec``. Tests that care about *what we
+asked ffmpeg to do* — the SSRF guards, the probe settings — need to see that
+argv without an ffmpeg binary being involved, so these patch the lookup and the
+spawn and record the call.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+
+def fake_ffmpeg():
+    """Pretend ffmpeg is installed, so the paths get as far as building argv."""
+    return patch("backend.app.services.external_camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg")
+
+
+def spawn_spy(returncode: int | None = 0, stdout: bytes = b"\xff\xd8" + b"\x00" * 200):
+    """Stand in for the ffmpeg subprocess, recording the argv it was handed.
+
+    The streaming path reads until EOF, so stdout.read returns b"" and the
+    generator finishes immediately — these tests are about whether ffmpeg was
+    launched and with what, not about frame extraction.
+    """
+    process = MagicMock()
+    process.returncode = returncode
+    process.communicate = AsyncMock(return_value=(stdout, b""))
+    process.stdout.read = AsyncMock(return_value=b"")
+    process.stderr.read = AsyncMock(return_value=b"")
+    process.wait = AsyncMock(return_value=returncode)
+    process.kill = MagicMock()
+    process.terminate = MagicMock()
+    return patch(
+        "backend.app.services.external_camera.asyncio.create_subprocess_exec",
+        new=AsyncMock(return_value=process),
+    )

+ 38 - 0
backend/tests/integration/test_ams_history_api.py

@@ -85,6 +85,44 @@ class TestAMSHistoryAPI:
         assert data["min_temperature"] == 24.0
         assert data["min_temperature"] == 24.0
         assert data["max_temperature"] == 26.0
         assert data["max_temperature"] == 26.0
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_average_of_zero_is_reported_as_zero(
+        self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
+    ):
+        """A window whose readings are all 0 has an average of 0, not "no data".
+
+        The response used to test the average for truthiness, so min and max
+        reported 0.0 while the average beside them came back null and the card
+        showed an em dash (#3140). Zero is rare but real -- a warm unit part way
+        through a drying cycle reaches it.
+        """
+        printer = await printer_factory()
+        await ams_history_factory(printer_id=printer.id, humidity=0.0, temperature=0.0)
+        await ams_history_factory(printer_id=printer.id, humidity=0.0, temperature=0.0)
+
+        response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
+        assert response.status_code == 200
+        data = response.json()
+
+        assert data["min_humidity"] == 0.0
+        assert data["avg_humidity"] == 0.0
+        assert data["avg_temperature"] == 0.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_empty_window_still_has_no_average(self, async_client: AsyncClient, printer_factory):
+        """The one case that genuinely has no answer must stay null."""
+        printer = await printer_factory()
+
+        response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
+        assert response.status_code == 200
+        data = response.json()
+
+        assert data["data"] == []
+        assert data["avg_humidity"] is None
+        assert data["avg_temperature"] is None
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_get_ams_history_with_hours_filter(
     async def test_get_ams_history_with_hours_filter(

+ 133 - 0
backend/tests/integration/test_archives_api.py

@@ -902,6 +902,39 @@ class TestArchivesAPI:
         assert response.status_code == 200, response.text
         assert response.status_code == 200, response.text
         assert response.json()["filament_used_grams"] == 12.5
         assert response.json()["filament_used_grams"] == 12.5
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_items_printed_accepts_zero_for_a_ruined_plate(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """A jam can ruin every part on the plate while the printer still
+        reports success (#3051). The project's completed-items count sums this
+        column, so zero has to be storable, not floored to one.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, quantity=4)
+
+        response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"quantity": 0})
+
+        assert response.status_code == 200, response.text
+        assert response.json()["quantity"] == 0
+        await db_session.refresh(archive)
+        assert archive.quantity == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_items_printed_refuses_a_negative_count(
+        self, async_client: AsyncClient, archive_factory, printer_factory
+    ):
+        """Zero means "nothing came off the plate"; below that would subtract
+        from the project totals this column feeds."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, quantity=4)
+
+        response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"quantity": -1})
+
+        assert response.status_code == 422, response.text
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_update_archive_failure_reason_mirrors_to_print_log_entry(
     async def test_update_archive_failure_reason_mirrors_to_print_log_entry(
@@ -1505,6 +1538,106 @@ class TestNo3MFWarningReason:
 
 
         assert response.json() == {"has_fallback": False, "reason": None}
         assert response.json() == {"has_fallback": False, "reason": None}
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_refused_handshake_is_reported(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#2957 stamps this slug and #2780 never taught the banner about it, so
+        an install whose only empty archives came from a printer refusing TLS on
+        port 990 was told the slicer had not written the file to the card. The
+        slicer had; nothing could read it back. The reporter could see the file
+        on the stick from his own computer, which is exactly why the advice read
+        as Bambuddy being broken.
+        """
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            extra_data={"no_3mf_available": True, "no_3mf_reason": "ftps_cooloff"},
+        )
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.json() == {"has_fallback": True, "reason": "ftps_cooloff"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_refused_handshake_outranks_every_settled_cause(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """The other three describe an install working as configured. This one
+        reports a printer doing something we cannot yet explain, and the banner
+        dismisses one-shot into localStorage -- so a reason ranked below another
+        is not deferred, it is never shown to that user at all.
+        """
+        printer = await printer_factory()
+        for reason in ("internal_storage", "no_external_storage", "internal_history"):
+            await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": reason})
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "ftps_cooloff"})
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.json() == {"has_fallback": True, "reason": "ftps_cooloff"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_settled_causes_keep_their_order_behind_it(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Adding a new leader must not disturb the ranking underneath it: an
+        install with no refused handshake still gets exactly what it got before.
+        """
+        printer = await printer_factory()
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_history"})
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "no_external_storage"})
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.json() == {"has_fallback": True, "reason": "no_external_storage"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_transfer_that_ran_out_of_time_is_reported(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#3063's P1S had the file on its card and served it three times in the
+        two minutes after the archive flow gave up on it. Told to switch on
+        "Store sent files on external storage", that reporter would be switching
+        on a setting that was already on and had already worked.
+        """
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            extra_data={"no_3mf_available": True, "no_3mf_reason": "ftp_transfer_failed"},
+        )
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.json() == {"has_fallback": True, "reason": "ftp_transfer_failed"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_slow_transfer_outranks_the_settled_causes_but_not_a_refused_handshake(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Both temporary causes describe a file still sitting on the card, so
+        both outrank the three that describe an install working as configured.
+        Between the two, a printer that will not complete a TLS handshake is the
+        worse fault and keeps the banner.
+        """
+        printer = await printer_factory()
+        for reason in ("internal_storage", "no_external_storage", "internal_history"):
+            await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": reason})
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "ftp_transfer_failed"})
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+        assert response.json() == {"has_fallback": True, "reason": "ftp_transfer_failed"}
+
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "ftps_cooloff"})
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+        assert response.json() == {"has_fallback": True, "reason": "ftps_cooloff"}
+
 
 
 class TestPrintLogEntryDelete:
 class TestPrintLogEntryDelete:
     """#1687: per-row delete on the Print Log page.
     """#1687: per-row delete on the Print Log page.

+ 89 - 0
backend/tests/integration/test_backup_manifest.py

@@ -0,0 +1,89 @@
+"""The backup says which version made it, and restore refuses one it cannot import.
+
+Restoring a backup into a different version of Bambuddy is ordinary -- an
+upgrade, a rebuild, a move to another host. What is not ordinary is the Postgres
+restore path, which throws the backup's schema away and rebuilds from the
+running ORM. A NOT NULL column the running version has and the backup does not
+then has nothing to put in it, and the import fails on the INSERT: after the
+drop, with the install's data already gone.
+
+So the incompatibility has to be found before any of that, and it has to say
+something an operator can act on. A column name does not; a pair of version
+numbers does, which is what the manifest is for.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import sqlite3
+import zipfile
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.core.config import APP_VERSION, settings as app_settings
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_backup_records_the_version_that_made_it(async_client, monkeypatch, tmp_path):
+    from backend.app.api.routes.settings import create_backup_zip
+
+    monkeypatch.setenv("DATA_DIR", str(tmp_path))
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+
+    zip_path, _filename = await create_backup_zip(output_path=tmp_path)
+    try:
+        with zipfile.ZipFile(zip_path) as zf:
+            assert "manifest.json" in zf.namelist()
+            manifest = json.loads(zf.read("manifest.json"))
+    finally:
+        zip_path.unlink(missing_ok=True)
+
+    assert manifest["app_version"] == APP_VERSION
+    assert manifest["format"] == 1
+    assert manifest["database"] in ("sqlite", "postgresql")
+    assert manifest["created_at"]
+
+
+def _incompatible_backup(tmp_path: Path, *, version: str) -> bytes:
+    """A backup whose `cost_centers` has no `name` -- NOT NULL, no default."""
+    db = tmp_path / "bambuddy.db"
+    conn = sqlite3.connect(db)
+    conn.execute("CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)")
+    conn.execute("INSERT INTO cost_centers (id, code) VALUES (1, 'abc')")
+    conn.commit()
+    conn.close()
+
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w") as zf:
+        zf.write(db, "bambuddy.db")
+        zf.writestr("manifest.json", json.dumps({"format": 1, "app_version": version}))
+    return buffer.getvalue()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_unimportable_backup_is_refused_with_both_versions(async_client, tmp_path):
+    """A 400 naming the two versions, and -- the point -- nothing touched.
+
+    is_sqlite is patched false because this is the PostgreSQL path: a SQLite
+    install restores by copying the backup's pages, schema and all, and has
+    never had this problem.
+    """
+    payload = _incompatible_backup(tmp_path, version="99.9.9")
+
+    with patch("backend.app.core.db_dialect.is_sqlite", return_value=False):
+        response = await async_client.post(
+            "/api/v1/settings/restore",
+            files={"file": ("backup.zip", payload, "application/zip")},
+        )
+
+    assert response.status_code == 400, response.text
+    detail = response.json()["detail"]
+    assert "cost_centers.name" in detail
+    assert "99.9.9" in detail
+    assert APP_VERSION in detail
+    assert "Nothing has been changed" in detail

+ 17 - 9
backend/tests/integration/test_cloud_auth.py

@@ -265,7 +265,7 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_get_stored_token_returns_none_when_no_user_no_global(self, db_session):
     async def test_get_stored_token_returns_none_when_no_user_no_global(self, db_session):
         """get_stored_token with user=None and no global token returns (None, None)."""
         """get_stored_token with user=None and no global token returns (None, None)."""
-        from backend.app.api.routes.cloud import get_stored_token
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         token, email, region = await get_stored_token(db_session, user=None)
         token, email, region = await get_stored_token(db_session, user=None)
         assert token is None
         assert token is None
@@ -275,7 +275,8 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_store_and_get_global_token(self, db_session):
     async def test_store_and_get_global_token(self, db_session):
         """store_token with user=None stores in global Settings table."""
         """store_token with user=None stores in global Settings table."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         await store_token(db_session, "test-token-123", "test@example.com", "global", user=None)
         await store_token(db_session, "test-token-123", "test@example.com", "global", user=None)
         token, email, region = await get_stored_token(db_session, user=None)
         token, email, region = await get_stored_token(db_session, user=None)
@@ -286,9 +287,10 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_store_and_get_per_user_token(self, db_session):
     async def test_store_and_get_per_user_token(self, db_session):
         """store_token with user stores on the user record."""
         """store_token with user stores on the user record."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
         from backend.app.core.auth import get_password_hash
         from backend.app.core.auth import get_password_hash
         from backend.app.models.user import User
         from backend.app.models.user import User
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         user = User(username="tokentest", password_hash=get_password_hash("pass"), role="user")
         user = User(username="tokentest", password_hash=get_password_hash("pass"), role="user")
         db_session.add(user)
         db_session.add(user)
@@ -309,9 +311,10 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_per_user_token_does_not_affect_global(self, db_session):
     async def test_per_user_token_does_not_affect_global(self, db_session):
         """Storing per-user token should not affect global Settings."""
         """Storing per-user token should not affect global Settings."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
         from backend.app.core.auth import get_password_hash
         from backend.app.core.auth import get_password_hash
         from backend.app.models.user import User
         from backend.app.models.user import User
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         user = User(username="isolationtest", password_hash=get_password_hash("pass"), role="user")
         user = User(username="isolationtest", password_hash=get_password_hash("pass"), role="user")
         db_session.add(user)
         db_session.add(user)
@@ -352,7 +355,8 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_clear_global_token(self, db_session):
     async def test_clear_global_token(self, db_session):
         """clear_token with user=None clears from global Settings."""
         """clear_token with user=None clears from global Settings."""
-        from backend.app.api.routes.cloud import clear_token, get_stored_token, store_token
+        from backend.app.api.routes.cloud import clear_token, store_token
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         await store_token(db_session, "global-token", "global@test.com", "global", user=None)
         await store_token(db_session, "global-token", "global@test.com", "global", user=None)
         await clear_token(db_session, user=None)
         await clear_token(db_session, user=None)
@@ -365,9 +369,10 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_two_users_independent_tokens(self, db_session):
     async def test_two_users_independent_tokens(self, db_session):
         """Two users should have completely independent cloud tokens and regions."""
         """Two users should have completely independent cloud tokens and regions."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
         from backend.app.core.auth import get_password_hash
         from backend.app.core.auth import get_password_hash
         from backend.app.models.user import User
         from backend.app.models.user import User
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         user_a = User(username="user_a", password_hash=get_password_hash("pass"), role="user")
         user_a = User(username="user_a", password_hash=get_password_hash("pass"), role="user")
         user_b = User(username="user_b", password_hash=get_password_hash("pass"), role="user")
         user_b = User(username="user_b", password_hash=get_password_hash("pass"), role="user")
@@ -406,9 +411,10 @@ class TestCloudRegionPersistence:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_region_survives_roundtrip_per_user(self, db_session):
     async def test_region_survives_roundtrip_per_user(self, db_session):
         """Stored China region is returned on subsequent get_stored_token calls."""
         """Stored China region is returned on subsequent get_stored_token calls."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
         from backend.app.core.auth import get_password_hash
         from backend.app.core.auth import get_password_hash
         from backend.app.models.user import User
         from backend.app.models.user import User
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         user = User(username="region-user", password_hash=get_password_hash("pass"), role="user")
         user = User(username="region-user", password_hash=get_password_hash("pass"), role="user")
         db_session.add(user)
         db_session.add(user)
@@ -429,7 +435,8 @@ class TestCloudRegionPersistence:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_region_survives_roundtrip_global_fallback(self, db_session):
     async def test_region_survives_roundtrip_global_fallback(self, db_session):
         """Stored China region in auth-disabled Settings fallback survives too."""
         """Stored China region in auth-disabled Settings fallback survives too."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         await store_token(db_session, "cn-token", "token-auth", "china", user=None)
         await store_token(db_session, "cn-token", "token-auth", "china", user=None)
         _token, _email, region = await get_stored_token(db_session, user=None)
         _token, _email, region = await get_stored_token(db_session, user=None)
@@ -438,7 +445,8 @@ class TestCloudRegionPersistence:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_invalid_region_is_normalised_to_global(self, db_session):
     async def test_invalid_region_is_normalised_to_global(self, db_session):
         """Unknown region values fall back to 'global' rather than mis-route."""
         """Unknown region values fall back to 'global' rather than mis-route."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         await store_token(db_session, "t", "x@test.com", "mars", user=None)
         await store_token(db_session, "t", "x@test.com", "mars", user=None)
         _token, _email, region = await get_stored_token(db_session, user=None)
         _token, _email, region = await get_stored_token(db_session, user=None)

+ 5 - 5
backend/tests/integration/test_cloud_token_auth_migration.py

@@ -20,16 +20,16 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.api.routes import cloud as cloud_routes
 from backend.app.api.routes import cloud as cloud_routes
-from backend.app.api.routes.cloud import (
+from backend.app.core.auth import get_password_hash
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services.bambu_cloud import BambuCloudError
+from backend.app.services.bambu_cloud_credentials import (
     CLOUD_EMAIL_KEY,
     CLOUD_EMAIL_KEY,
     CLOUD_REGION_KEY,
     CLOUD_REGION_KEY,
     CLOUD_TOKEN_KEY,
     CLOUD_TOKEN_KEY,
     get_stored_token,
     get_stored_token,
 )
 )
-from backend.app.core.auth import get_password_hash
-from backend.app.models.settings import Settings
-from backend.app.models.user import User
-from backend.app.services.bambu_cloud import BambuCloudError
 
 
 
 
 async def _seed_global_token(db: AsyncSession, token: str = "tok-global", region: str = "china") -> None:
 async def _seed_global_token(db: AsyncSession, token: str = "tok-global", region: str = "china") -> None:

+ 303 - 0
backend/tests/integration/test_external_spool_use_ams_3087.py

@@ -0,0 +1,303 @@
+"""A plate printed entirely from the external spool must dispatch use_ams=False (#3087).
+
+The reporter's P1S sat at "Heatbed preheating" for ten and a half minutes and
+then paused with 07FF_8012, "Failed to get AMS mapping table". The plate was one
+filament, mapped by hand to the external spool, out of a seven-filament
+MakerWorld project -- so the mapping was ``[-1, -1, -1, -1, -1, -1, 254]`` and
+the command went out as ``use_ams: true`` with a flat mapping of nothing but
+-1 (254 is deliberately not sent raw: the firmware reads it as AMS tray 0).
+
+The decision belongs here rather than in the MQTT command builder. Down there a
+-1 is either padding for a filament this plate does not print -- BambuStudio's
+own convention, and what the other six entries are -- or a slot that never
+resolved, which must never be redirected to the spool holder (#2589). The two
+are the same byte. Only the plate's own filament list tells them apart, and
+``extract_filament_requirements`` already drops anything with ``used_g <= 0``,
+so it names exactly the slots that are printed.
+"""
+
+from __future__ import annotations
+
+import json
+import zipfile
+from contextlib import ExitStack
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+import backend.app.services.print_scheduler as scheduler_module
+from backend.app.core.database import Base
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings  # noqa: F401 - registers the table
+from backend.app.services.print_scheduler import PrintScheduler
+from backend.tests._fixtures.background_tasks import discarding_spawn_patch
+
+pytestmark = pytest.mark.integration
+
+# The reporter's plate: filament 7 of a seven-filament project, and it is the
+# only one this plate consumes. slice_info.config lists a plate's filaments by
+# their project-wide id, which is why the mapping is seven long.
+_PLATE_4_ONE_FILAMENT = '<filament id="7" used_g="12.4" type="PLA" color="#F98C36"/>'
+
+
+def _write_3mf(path: Path, plate_index: int = 4, filaments: str = _PLATE_4_ONE_FILAMENT) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr(
+            "Metadata/slice_info.config",
+            f'<config><plate><metadata key="index" value="{plate_index}"/>{filaments}</plate></config>',
+        )
+
+
+def _write_3mf_without_slice_info(path: Path) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr("3D/3dmodel.model", "<model/>")
+
+
+@pytest.fixture
+async def dispatch_case(tmp_path):
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+    base_dir = tmp_path / "external-spool"
+
+    async def _build(
+        mapping, *, use_ams=True, plate_id=4, filaments=_PLATE_4_ONE_FILAMENT, slice_info=True, model="P1S"
+    ):
+        archive_rel = Path("archives") / f"plate-{plate_id}-{abs(hash(str(mapping))) % 10**6}.gcode.3mf"
+        if slice_info:
+            _write_3mf(base_dir / archive_rel, plate_index=plate_id, filaments=filaments)
+        else:
+            _write_3mf_without_slice_info(base_dir / archive_rel)
+
+        async with session_maker() as db:
+            printer = Printer(
+                name="P1S",
+                serial_number=f"01P{abs(hash(str(mapping))) % 10**9}",
+                ip_address="127.0.0.1",
+                access_code="access-code",
+                model=model,
+            )
+            db.add(printer)
+            await db.flush()
+            archive = PrintArchive(
+                printer_id=printer.id,
+                filename=archive_rel.name,
+                file_path=str(archive_rel),
+                file_size=(base_dir / archive_rel).stat().st_size,
+                status="completed",
+            )
+            db.add(archive)
+            await db.flush()
+            item = PrintQueueItem(
+                printer_id=printer.id,
+                archive_id=archive.id,
+                plate_id=plate_id,
+                status="pending",
+                use_ams=use_ams,
+                ams_mapping=json.dumps(mapping) if mapping is not None else None,
+            )
+            db.add(item)
+            await db.commit()
+            return SimpleNamespace(item_id=item.id, printer_id=printer.id)
+
+    try:
+        yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, build=_build)
+    finally:
+        await engine.dispose()
+
+
+async def _dispatch(ctx, ids, status=None):
+    scheduler = PrintScheduler()
+    start_print = MagicMock(return_value=True)
+    status = status or SimpleNamespace(state="IDLE", nozzle_rack=None, raw_data={}, nozzles=[])
+
+    with ExitStack() as stack:
+        for patcher in (
+            patch.object(scheduler_module, "async_session", ctx.session_maker),
+            patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
+            patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
+            patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print),
+            patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
+            patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
+            patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
+            patch(
+                "backend.app.services.print_scheduler.get_ftp_retry_settings",
+                AsyncMock(return_value=(False, 3, 2.0, 30.0)),
+            ),
+            patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
+            discarding_spawn_patch(),
+            patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
+            patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
+            patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
+            patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
+            patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
+            patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
+        ):
+            stack.enter_context(patcher)
+        await scheduler._dispatch_one(ids.item_id)
+
+    assert start_print.call_count == 1, "the print command was never sent"
+    return start_print.call_args
+
+
+class TestThePlateThatOnlyPrintsFromTheSpoolHolder:
+    async def test_the_reporters_mapping_dispatches_without_the_ams(self, dispatch_case):
+        """[-1]*6 + [254] on a plate whose only printed filament is #7."""
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254])
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is False
+        # The mapping itself still goes out untouched — the builder is what
+        # turns 254 into -1 plus ams_mapping2, and none of that changes.
+        assert call.kwargs["ams_mapping"] == [-1, -1, -1, -1, -1, -1, 254]
+
+    async def test_the_main_nozzle_sentinel_counts_too(self, dispatch_case):
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 255])
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is False
+
+    async def test_an_unpadded_single_filament_plate_is_unaffected(self, dispatch_case):
+        """[254] already worked: the MQTT command builder downgrades an
+        all-external mapping by itself. The scheduler now reaches the same
+        answer one layer earlier, so the two agree rather than one undoing the
+        other — this pins that they do."""
+        ids = await dispatch_case.build([254], filaments='<filament id="1" used_g="9.0" type="PLA"/>', plate_id=1)
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is False
+
+
+class TestWhatMustNotChange:
+    async def test_a_consumed_slot_that_never_resolved_still_goes_out_with_the_ams(self, dispatch_case):
+        """The #2589 contract, and the reason this lives in the scheduler.
+
+        Filaments 1 and 7 are both printed; 7 is on the spool holder and 1
+        resolved to nothing. Redirecting the plate to the external spool would
+        print filament 1 in the wrong material without saying so. use_ams stays
+        true and the firmware rejects the print, exactly as before.
+        """
+        ids = await dispatch_case.build(
+            [-1, -1, -1, -1, -1, -1, 254],
+            filaments='<filament id="1" used_g="8.0" type="PETG"/>' + _PLATE_4_ONE_FILAMENT,
+        )
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_a_plate_mixing_an_ams_tray_with_the_spool_holder_keeps_the_ams(self, dispatch_case):
+        ids = await dispatch_case.build(
+            [5, -1, -1, -1, -1, -1, 254],
+            filaments='<filament id="1" used_g="8.0" type="PETG"/>' + _PLATE_4_ONE_FILAMENT,
+        )
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_a_plate_printed_from_ams_trays_is_untouched(self, dispatch_case):
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 5])
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_use_ams_false_is_never_promoted_here(self, dispatch_case):
+        """Promotion is the builder's job (#2595) and stays there."""
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 5], use_ams=False)
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is False
+
+    async def test_a_3mf_with_no_filament_list_falls_back_to_the_stored_flag(self, dispatch_case):
+        """No evidence, no decision — the same convention as #2771."""
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], slice_info=False)
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_a_plate_the_file_does_not_describe_falls_back(self, dispatch_case):
+        """The item says plate 4; the file only describes plate 1."""
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], plate_id=4)
+        # Rewrite the archive's 3MF so its only plate is index 1.
+        async with dispatch_case.session_maker() as db:
+            archive = (await db.get(PrintQueueItem, ids.item_id)).archive_id
+            path = dispatch_case.base_dir / (await db.get(PrintArchive, archive)).file_path
+        _write_3mf(path, plate_index=1)
+
+        call = await _dispatch(dispatch_case, ids)
+        assert call.kwargs["use_ams"] is True
+
+    async def test_an_item_with_no_mapping_at_all_is_untouched(self, dispatch_case):
+        ids = await dispatch_case.build(None)
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+
+class TestDualNozzleIsNotOursToRewrite:
+    """On a two-extruder printer use_ams is which nozzle to feed, not whether to
+    use the AMS — H2D Pro firmware reads it as an extruder index. The MQTT
+    command builder skips its own reconcile for exactly that reason, and this
+    must skip it too, or a perfectly normal dual external-spool print gets its
+    routing rewritten."""
+
+    async def test_a_dual_nozzle_model_keeps_its_flag(self, dispatch_case):
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="H2D")
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_both_external_feeds_on_a_dual_nozzle_are_left_alone(self, dispatch_case):
+        """254 is the deputy feed and 255 the main one — an ordinary H2D print
+        with a spool on each side, and the one this would have broken."""
+        ids = await dispatch_case.build(
+            [254, -1, -1, -1, -1, -1, 255],
+            filaments='<filament id="1" used_g="8.0" type="PLA"/>' + _PLATE_4_ONE_FILAMENT,
+            model="H2D",
+        )
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_live_telemetry_can_veto_a_single_nozzle_model_name(self, dispatch_case):
+        """A model string we do not recognise as dual is not the last word: two
+        external feeds is something only a two-extruder printer reports."""
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="Something New")
+        status = SimpleNamespace(
+            state="IDLE",
+            nozzle_rack=None,
+            nozzles=[],
+            raw_data={"vt_tray": [{"id": "254"}, {"id": "255"}]},
+        )
+        call = await _dispatch(dispatch_case, ids, status=status)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_a_second_nozzle_reporting_a_diameter_vetoes_it_too(self, dispatch_case):
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="Something New")
+        status = SimpleNamespace(
+            state="IDLE",
+            nozzle_rack=None,
+            nozzles=[SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="0.4")],
+            raw_data={},
+        )
+        call = await _dispatch(dispatch_case, ids, status=status)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_h2s_is_single_nozzle_and_still_gets_the_fix(self, dispatch_case):
+        """H2S shares the H2 serial prefix and firmware quirks but has one
+        extruder — the #1386 distinction, which must survive here."""
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="H2S")
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is False

+ 77 - 0
backend/tests/integration/test_finance_api.py

@@ -1191,3 +1191,80 @@ class TestFinanceUserDefaults:
 
 
         wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
         wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
         assert wallet is not None
         assert wallet is not None
+
+
+class TestFinanceCurrency(TestFinanceAPI):
+    """#3123: every balance is reported in the install's configured currency.
+
+    Wallets used to carry a currency of their own, which three of its four
+    writers filled with a hardcoded "EUR" and the Finance page rendered as it
+    found it -- so an install set to AUD showed a euro balance. The column is
+    gone; these tests pin what replaced it.
+    """
+
+    @pytest.fixture
+    async def aud_install(self, db_session):
+        existing = await db_session.scalar(select(Settings).where(Settings.key == "currency"))
+        if existing is None:
+            db_session.add(Settings(key="currency", value="AUD"))
+        else:
+            existing.value = "AUD"
+        await db_session.commit()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_balance_reports_the_configured_currency_without_a_wallet(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+        admin_user,
+        aud_install,
+    ):
+        """The read-only path used to answer a flat "EUR" when no wallet row existed."""
+        assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == admin_user.id)) is None
+
+        response = await async_client.get("/api/v1/finance/me/balance", headers=auth_headers)
+
+        assert response.status_code == 200
+        assert response.json()["currency"] == "AUD"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_existing_wallet_is_reported_in_the_configured_currency(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+        admin_user,
+        aud_install,
+    ):
+        """The reporter's case: a wallet created back when the install said EUR."""
+        db_session.add(UserWallet(user_id=admin_user.id, balance=12.34))
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/finance/me/balance", headers=auth_headers)
+
+        assert response.status_code == 200
+        assert response.json()["balance"] == 12.34
+        assert response.json()["currency"] == "AUD"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_adjustment_answers_in_the_configured_currency(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+        admin_user,
+        aud_install,
+    ):
+        """_get_or_create_wallet is the path that used to write EUR into the database."""
+        response = await async_client.post(
+            f"/api/v1/finance/users/{admin_user.id}/deposit",
+            json={"amount": 5.0, "description": "currency check"},
+            headers=auth_headers,
+        )
+
+        assert response.status_code == 200, response.text
+        assert response.json()["balance"]["currency"] == "AUD"

+ 360 - 2
backend/tests/integration/test_inventory_assign.py

@@ -1226,6 +1226,221 @@ class TestAssignSpoolEmptyDetection:
         assert body["configured"] is True
         assert body["configured"] is True
 
 
 
 
+class TestAssignSpoolPresenceBit:
+    """#3084: the slot the firmware says is full, and the cache says is empty.
+
+    ``apply_tray_exist_bits`` stamps ``state = 9`` on every slot whose
+    ``tray_exist_bits`` bit is 0 and annotates ``exists`` on every slot it
+    looks at — but when the bit comes back it only refreshes ``exists`` and
+    leaves the 9 where it was. Swap a Bambu spool for a non-RFID one and the
+    slot sits at ``exists=True, state=9`` until something configures it.
+
+    Reported on an H2D/H2C AMS-HT: remove the Bambu spool (bits ``f``), insert
+    a third-party one 9 seconds later (bits ``1000f``), then Assign Spool 28
+    seconds after that — and no ``ams_filament_setting`` was published at all.
+    Configure worked, because it publishes unconditionally.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_bit_overrules_a_stale_empty_state(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """exists=True with a leftover state=9 — MQTT must fire."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        tray_data = {"id": 0, "state": 9, "exists": True, "tray_type": "", "tray_color": "", "tray_info_idx": ""}
+        status = _make_mock_status(ams_data=[{"id": 128, "tray": [tray_data]}])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 128, "tray_id": 0},
+            )
+
+        assert response.status_code == 200
+        mock_client.ams_set_filament_setting.assert_called_once()
+        body = response.json()
+        assert body["configured"] is True
+        assert body["pending_config"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_empty_bit_does_not_start_suppressing_pushes(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """The bit overrules the 9 and nothing else.
+
+        Reading it the other way too would be tidier — skip the push firmware
+        is going to drop — but it also means a slot that silently stops
+        configuring on whichever AMS variant we compute the bit position
+        wrong for. The saving is one MQTT message; the failure is the bug
+        this commit is fixing, inverted. So a state that does not say "empty"
+        still publishes, exactly as it did before.
+        """
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        tray_data = {"id": 3, "state": 11, "exists": False, "tray_type": "", "tray_color": ""}
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+        assert response.status_code == 200
+        mock_client.ams_set_filament_setting.assert_called_once()
+        assert response.json()["pending_config"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_pre_assign_workflow_still_skips_a_genuinely_empty_slot(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """SpoolBuddy weighs a spool and assigns it before it goes in. Bit
+        clear and state 9 agree that the slot is empty, so the push is still
+        deferred to the replay — unchanged."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+
+        tray_data = {"id": 3, "state": 9, "exists": False, "tray_type": "", "tray_color": ""}
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+        assert response.status_code == 200
+        mock_client.ams_set_filament_setting.assert_not_called()
+        body = response.json()
+        assert body["configured"] is False
+        assert body["pending_config"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_unannotated_tray_still_reads_the_state(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """No presence bit in the payload → the 9/10 heuristic still decides.
+
+        The external spool's ``vt_tray`` has no bit in the mask, and neither do
+        the hand-built payloads every other test in this file uses.
+        """
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "state": 9, "tray_type": ""}]}])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+        assert response.status_code == 200
+        mock_client.ams_set_filament_setting.assert_not_called()
+        assert response.json()["pending_config"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deferred_config_fires_for_a_spool_the_ams_cannot_name(
+        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
+    ):
+        """The pre-assign workflow's half of the same bug.
+
+        A non-RFID spool inserted into a pre-assigned slot brings no
+        ``tray_type`` with it, and the stale 9 kept the replay's "loaded" test
+        false, so the deferred configuration never fired for it either. The
+        presence bit is the only thing in the payload that changed.
+        """
+        from unittest.mock import AsyncMock
+
+        from backend.app.main import on_ams_change
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        pre_assignment = SpoolAssignment(
+            spool_id=spool.id,
+            printer_id=printer.id,
+            ams_id=2,
+            tray_id=3,
+            fingerprint_color=None,
+            fingerprint_type=None,
+        )
+        db_session.add(pre_assignment)
+        await db_session.commit()
+
+        ams_data = [{"id": 2, "tray": [{"id": 3, "state": 9, "exists": True, "tray_type": "", "tray_color": ""}]}]
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=ams_data)
+
+        with (
+            patch("backend.app.main.printer_manager") as mock_pm_main,
+            patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.ws_manager") as mock_ws,
+        ):
+            mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
+            mock_pm_main.get_status.return_value = status
+            mock_pm_main.get_client.return_value = mock_client
+            mock_pm_main.get_model.return_value = "H2D"
+            mock_pm_inv.get_client.return_value = mock_client
+            mock_pm_inv.get_status.return_value = status
+            mock_relay.on_ams_change = AsyncMock()
+            mock_ws.send_printer_status = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+
+            await on_ams_change(printer.id, ams_data)
+
+        mock_client.ams_set_filament_setting.assert_called_once()
+        call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
+        assert call_kwargs["ams_id"] == 2
+        assert call_kwargs["tray_id"] == 3
+        assert call_kwargs["tray_info_idx"] == "GFL05"
+
+        # The assignment is still there — the pass that fires the config is the
+        # same pass that deletes stale ones (#3100).
+        db_session.expunge_all()
+        assert await db_session.get(SpoolAssignment, pre_assignment.id) is not None
+
+
 class TestAssignSpoolPfcnCloudPreset:
 class TestAssignSpoolPfcnCloudPreset:
     """Assign path for PFCN-prefix cloud presets (#1648).
     """Assign path for PFCN-prefix cloud presets (#1648).
 
 
@@ -1561,6 +1776,118 @@ class TestAutoUnlinkDuringRunout:
         assert await db_session.get(SpoolAssignment, assignment_id) is not None
         assert await db_session.get(SpoolAssignment, assignment_id) is not None
 
 
 
 
+class TestAutoUnlinkOccupiedSlot:
+    """#3100: six saved assignments deleted across three X1 Carbons.
+
+    Each one had an explicit earlier assignment and then an ``Auto-unlink ...
+    fingerprint mismatch``, and the reporter recovered the mappings from logs
+    because they were gone from the inventory API, not merely hidden. Two
+    shapes, one cause: a slot the presence bit calls occupied while the tray
+    reports nothing about what is in it.
+
+    ``cur=/ fp=BCBCBCFF/PLA spool=8A8F92FF/PLA`` — an established assignment,
+    a blank idle report, and the row deleted. The spool never went anywhere;
+    the AMS simply had nothing to say about a filament it cannot read.
+    """
+
+    @staticmethod
+    async def _run(printer_id, ams_data, status):
+        from unittest.mock import AsyncMock
+
+        from backend.app.main import on_ams_change
+
+        with (
+            patch("backend.app.main.printer_manager") as mock_pm_main,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.ws_manager") as mock_ws,
+        ):
+            mock_pm_main.get_printer.return_value = MagicMock(name="X1C", serial_number="0948BB540200427")
+            mock_pm_main.get_status.return_value = status
+            mock_pm_main.get_model.return_value = "X1C"
+            mock_pm_main.get_client.return_value = None
+            mock_relay.on_ams_change = AsyncMock()
+            mock_ws.send_printer_status = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+
+            await on_ams_change(printer_id, ams_data)
+
+    @staticmethod
+    async def _assignment(db_session, printer, spool):
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        assignment = SpoolAssignment(
+            spool_id=spool.id,
+            printer_id=printer.id,
+            ams_id=1,
+            tray_id=1,
+            fingerprint_color="BCBCBCFF",
+            fingerprint_type="PLA",
+        )
+        db_session.add(assignment)
+        await db_session.commit()
+        return assignment.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_blank_report_from_an_occupied_slot_keeps_the_assignment(
+        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
+    ):
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(material="PLA", rgba="8A8F92FF")
+        assignment_id = await self._assignment(db_session, printer, spool)
+
+        ams_data = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
+        await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
+
+        db_session.expunge_all()
+        assert await db_session.get(SpoolAssignment, assignment_id) is not None, (
+            "a spool the AMS cannot identify is not a spool that was removed"
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_blank_report_from_an_empty_slot_still_unlinks(
+        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
+    ):
+        """The guard reads the bit, not the blankness — take the spool out and
+        the assignment still goes, which is what makes the test above a
+        distinction rather than a blanket reprieve."""
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(material="PLA", rgba="8A8F92FF")
+        assignment_id = await self._assignment(db_session, printer, spool)
+
+        ams_data = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
+        await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
+
+        db_session.expunge_all()
+        assert await db_session.get(SpoolAssignment, assignment_id) is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_genuinely_different_filament_in_an_occupied_slot_still_unlinks(
+        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
+    ):
+        """The guard is for a blank report only. A slot that names a filament
+        which is not the assigned spool is a swap, bit set or not."""
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(material="PLA", rgba="8A8F92FF")
+        assignment_id = await self._assignment(db_session, printer, spool)
+
+        ams_data = [
+            {"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "PETG", "tray_color": "00FF00FF", "state": 11}]}
+        ]
+        await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
+
+        db_session.expunge_all()
+        assert await db_session.get(SpoolAssignment, assignment_id) is None
+
+
 class TestSpoolmanSlotAssignmentDuringRunout:
 class TestSpoolmanSlotAssignmentDuringRunout:
     """`spoolman_slot_assignments` is how a tag-less spool assigned through the
     """`spoolman_slot_assignments` is how a tag-less spool assigned through the
     Bambuddy UI is resolved at completion (#1459). Deleting the row when a slot
     Bambuddy UI is resolved at completion (#1459). Deleting the row when a slot
@@ -1578,14 +1905,14 @@ class TestSpoolmanSlotAssignmentDuringRunout:
             db_session.add(Settings(key=key, value=value))
             db_session.add(Settings(key=key, value=value))
         await db_session.commit()
         await db_session.commit()
 
 
-    async def _run(self, printer_id, state):
+    async def _run(self, printer_id, state, tray=None):
         from unittest.mock import AsyncMock
         from unittest.mock import AsyncMock
 
 
         from backend.app.main import on_ams_change
         from backend.app.main import on_ams_change
 
 
         # A tray the firmware has cleared: parse_ams_tray returns None, which
         # A tray the firmware has cleared: parse_ams_tray returns None, which
         # is what marks the slot empty for the cleanup pass.
         # is what marks the slot empty for the cleanup pass.
-        ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
+        ams_data = [{"id": 0, "tray": [tray or {"id": 2, "tray_type": "", "tray_color": "", "state": 26}]}]
 
 
         spoolman_client = MagicMock()
         spoolman_client = MagicMock()
         spoolman_client.health_check = AsyncMock(return_value=True)
         spoolman_client.health_check = AsyncMock(return_value=True)
@@ -1628,6 +1955,37 @@ class TestSpoolmanSlotAssignmentDuringRunout:
         db_session.expunge_all()
         db_session.expunge_all()
         assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
         assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_occupied_slot_the_ams_cannot_read_keeps_its_row(
+        self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
+    ):
+        """Spoolman mode's half of #3100.
+
+        parse_ams_tray calls a tray with no type empty, and a tag-less spool
+        has none until something configures it — so the row assigned through
+        the UI was deleted by the first idle push after the spool went in.
+        Firmware's presence bit is the same answer the built-in inventory
+        uses, so the two modes stay in step.
+        """
+        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+        await self._enable_spoolman(db_session)
+        printer = await printer_factory(name="H2D")
+        row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
+        db_session.add(row)
+        await db_session.commit()
+        row_id = row.id
+
+        await self._run(
+            printer.id,
+            _make_printing_status(None, state="IDLE"),
+            tray={"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9},
+        )
+
+        db_session.expunge_all()
+        assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_the_slot_row_is_still_cleaned_up_when_idle(
     async def test_the_slot_row_is_still_cleaned_up_when_idle(

+ 155 - 0
backend/tests/integration/test_inventory_link_tag.py

@@ -0,0 +1,155 @@
+"""Conflict handling on PATCH /api/v1/inventory/spools/{id}/link-tag (#3110).
+
+The route loaded the conflicting spool row and then threw it away, refusing
+with a bare "already linked to another active spool" -- so a client could not
+tell which spool to look at, and could not offer to move the tag. It also read
+that row with ``scalar_one_or_none()``, which raises ``MultipleResultsFound``
+when two active spools carry one tag. Nothing prevents that duplicate: no
+unique index, no conflict check on PATCH /spools/{id}, and POST /spools/bulk
+copies one tag into every row it creates.
+
+That exception escapes the route into the auth middleware's fail-closed
+``except Exception`` (main.py:9685), so the caller does not even get a 500 --
+they get 503 "Authentication service temporarily unavailable" for a request
+that has nothing to do with auth. The middleware is right to fail closed
+(GHSA-6mf4-q26m-47pv); the route is what must not raise.
+"""
+
+from datetime import datetime, timezone
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+
+TAG = "AABBCCDD"
+TRAY_UUID = "AABBCCDDEEFF0011AABBCCDDEEFF0011"
+
+
+@pytest.fixture
+async def spool_factory(db_session: AsyncSession):
+    async def _create_spool(**kwargs):
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Devil Design",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "weight_used": 0,
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create_spool
+
+
+class TestLinkTagNamesTheHolder:
+    """The 409 carries the id the route already had in hand."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_uid_conflict_names_the_spool_holding_it(self, async_client: AsyncClient, spool_factory):
+        holder = await spool_factory(tag_uid=TAG)
+        target = await spool_factory()
+
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
+
+        assert resp.status_code == 409
+        detail = resp.json()["detail"]
+        assert detail["code"] == "tag_already_linked"
+        assert detail["spool_id"] == holder.id
+        assert detail["field"] == "tag_uid"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tray_uuid_conflict_names_the_spool_holding_it(self, async_client: AsyncClient, spool_factory):
+        holder = await spool_factory(tray_uuid=TRAY_UUID)
+        target = await spool_factory()
+
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tray_uuid": TRAY_UUID})
+
+        assert resp.status_code == 409
+        detail = resp.json()["detail"]
+        assert detail["spool_id"] == holder.id
+        # Which identifier collided, so a client knows what it would be moving.
+        assert detail["field"] == "tray_uuid"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_free_tag_still_links(self, async_client: AsyncClient, spool_factory):
+        """Regression guard: the conflict rewrite must not refuse a clean link."""
+        target = await spool_factory()
+
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
+
+        assert resp.status_code == 200
+        assert resp.json()["tag_uid"] == TAG
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_archived_holder_still_yields_the_tag(self, async_client: AsyncClient, spool_factory):
+        """Regression guard: tag recycling off archived spools is untouched."""
+        archived = await spool_factory(tag_uid=TAG, archived_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
+        target = await spool_factory()
+
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
+
+        assert resp.status_code == 200
+        reread = await async_client.get(f"/api/v1/inventory/spools/{archived.id}")
+        assert reread.json()["tag_uid"] is None
+
+
+class TestLinkTagDuplicateHolders:
+    """Two active spools on one tag is a 409 naming the lowest id, not a crash."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_duplicate_tag_uid_holders_yield_a_409_not_a_crash(self, async_client: AsyncClient, spool_factory):
+        first = await spool_factory(tag_uid=TAG)
+        await spool_factory(tag_uid=TAG)
+        target = await spool_factory()
+
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["spool_id"] == first.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_duplicate_tray_uuid_holders_yield_a_409_not_a_crash(self, async_client: AsyncClient, spool_factory):
+        first = await spool_factory(tray_uuid=TRAY_UUID)
+        await spool_factory(tray_uuid=TRAY_UUID)
+        target = await spool_factory()
+
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tray_uuid": TRAY_UUID})
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["spool_id"] == first.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_create_is_one_route_to_that_duplicate(self, async_client: AsyncClient, spool_factory):
+        """POST /spools/bulk copies a single payload -- tag included -- N times.
+
+        Reached through the API rather than the fixture, so the duplicate is
+        shown to be a state the app itself produces, not one only a test can
+        stage.
+        """
+        created = await async_client.post(
+            "/api/v1/inventory/spools/bulk",
+            json={"quantity": 2, "spool": {"material": "PLA", "label_weight": 1000, "tag_uid": TAG}},
+        )
+        assert created.status_code in (200, 201)
+        ids = sorted(s["id"] for s in created.json())
+        assert len(ids) == 2
+
+        target = await spool_factory()
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["spool_id"] == ids[0]

+ 204 - 9
backend/tests/integration/test_library_api.py

@@ -7,6 +7,16 @@ from pathlib import Path
 
 
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.core.config import settings as app_settings
+from backend.app.models.print_queue import PrintQueueItem
+
+
+async def _read_queue_item(db_session, item_id: int) -> PrintQueueItem:
+    """Re-read a queue row the route just committed through its own session."""
+    db_session.expire_all()
+    return (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
 
 
 
 
 class TestLibraryFoldersAPI:
 class TestLibraryFoldersAPI:
@@ -679,19 +689,43 @@ class TestLibraryAddToQueueAPI:
 
 
         return _create_library_file
         return _create_library_file
 
 
+    @pytest.fixture
+    async def on_disk_file_factory(self, library_file_factory):
+        """A library file whose bytes exist, so the route gets past its disk check."""
+        written: list[Path] = []
+
+        async def _create(**kwargs):
+            counter = len(written) + 1
+            rel_path = kwargs.pop("file_path", f"archive/library/files/queue_probe_{counter}.gcode.3mf")
+            abs_path = Path(app_settings.base_dir) / rel_path
+            abs_path.parent.mkdir(parents=True, exist_ok=True)
+            abs_path.write_bytes(b"probe")
+            written.append(abs_path)
+            kwargs.setdefault("filename", f"queue_probe_{counter}.gcode.3mf")
+            return await library_file_factory(file_path=rel_path, **kwargs)
+
+        yield _create
+
+        for path in written:
+            path.unlink(missing_ok=True)
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_add_to_queue_file_not_found(self, async_client: AsyncClient, printer_factory, db_session):
     async def test_add_to_queue_file_not_found(self, async_client: AsyncClient, printer_factory, db_session):
-        """Verify error for non-existent file."""
+        """Nothing queued is not a success (#3112).
+
+        This used to assert 200: the caller got an OK for a call that created
+        nothing, with the reason in a body it had no cause to read. The reason
+        is still reported, now where a failed call puts it.
+        """
         await printer_factory()
         await printer_factory()
 
 
         data = {"file_ids": [9999]}
         data = {"file_ids": [9999]}
         response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
         response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
-        assert response.status_code == 200
-        result = response.json()
-        assert len(result["added"]) == 0
-        assert len(result["errors"]) == 1
-        assert result["errors"][0]["file_id"] == 9999
+        assert response.status_code == 400
+        errors = response.json()["detail"]["errors"]
+        assert len(errors) == 1
+        assert errors[0]["file_id"] == 9999
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -708,11 +742,172 @@ class TestLibraryAddToQueueAPI:
 
 
         data = {"file_ids": [lib_file.id]}
         data = {"file_ids": [lib_file.id]}
         response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
         response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
+        assert response.status_code == 400
+        errors = response.json()["detail"]["errors"]
+        assert len(errors) == 1
+        assert "sliced" in errors[0]["error"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_partial_success_still_returns_200(
+        self, async_client: AsyncClient, printer_factory, library_file_factory, on_disk_file_factory, db_session
+    ):
+        """Items really were created, so the call succeeded (#3112).
+
+        The per-file errors ride along with them, which is the whole point of a
+        bulk endpoint. Only a call that produced nothing is a failed call.
+        """
+        await printer_factory()
+        good = await on_disk_file_factory()
+        bad = await library_file_factory(filename="model.stl", file_path="/test/path/model.stl", file_type="stl")
+
+        response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [good.id, bad.id]})
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
-        assert len(result["added"]) == 0
-        assert len(result["errors"]) == 1
-        assert "sliced" in result["errors"][0]["error"].lower()
+        assert [a["file_id"] for a in result["added"]] == [good.id]
+        assert [e["file_id"] for e in result["errors"]] == [bad.id]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_model_is_inferred_so_the_item_can_be_dispatched(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        """#3112: an item with no printer and no target model is inert.
+
+        The scheduler dispatches on `item.printer_id` or on
+        `item.target_model or item.variants`; a row with neither matches no
+        branch and waits forever. With an active X1C present, a file that says
+        it was sliced for one is aimed at it.
+        """
+        await printer_factory(model="X1C")
+        lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
+
+        response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
+        assert response.status_code == 200
+        item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
+        assert item.printer_id is None
+        assert item.target_model == "X1C"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_printer_of_that_model_leaves_the_item_unassigned(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        """Aiming an item at hardware nobody owns would only look like progress.
+
+        Having no H2D is the user's situation, not their mistake, so the file
+        is still queued -- as the unassigned row it has always been.
+        """
+        await printer_factory(model="X1C")
+        lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "H2D"})
+
+        response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
+        assert response.status_code == 200
+        item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
+        assert item.printer_id is None
+        assert item.target_model is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_explicit_printer_wins_over_the_files_own_model(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        printer = await printer_factory(model="X1C")
+        # Read before the queue row is re-read: that expires the session, and
+        # a lazy refresh of this row would then happen outside the greenlet.
+        printer_id = printer.id
+        lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [lib_file.id], "printer_id": printer_id},
+        )
+        assert response.status_code == 200
+        item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
+        assert item.printer_id == printer_id
+        assert item.target_model is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_incompatible_target_model_is_refused_per_file(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        """The same cross-model gate POST /queue/ applies (#2578).
+
+        The scheduler hands model-based items to hardware with no human in the
+        loop, so a file sliced for one model must not be aimed at another.
+        """
+        await printer_factory(model="A1")
+        lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [lib_file.id], "target_model": "A1"},
+        )
+        assert response.status_code == 400
+        assert "cannot be dispatched" in response.json()["detail"]["errors"][0]["error"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_model_without_an_active_printer_is_refused(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        await printer_factory(model="X1C")
+        lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "H2D"})
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [lib_file.id], "target_model": "H2D"},
+        )
+        assert response.status_code == 400
+        assert "No active printers" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_printer_and_target_model_together_are_refused(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        printer = await printer_factory(model="X1C")
+        lib_file = await on_disk_file_factory()
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [lib_file.id], "printer_id": printer.id, "target_model": "X1C"},
+        )
+        assert response.status_code == 400
+        assert "both" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_printer_is_refused(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        await printer_factory(model="X1C")
+        lib_file = await on_disk_file_factory()
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [lib_file.id], "printer_id": 999999},
+        )
+        assert response.status_code == 400
+        assert response.json()["detail"] == "Printer not found"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_filename_the_printer_cannot_store_is_refused(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        """The Bambu SD card is FAT32; an illegal character 553s at upload.
+
+        POST /queue/ has rejected these at queue time since #1540. This route
+        did not, so the mistake surfaced as a print that failed later.
+        """
+        await printer_factory(model="X1C")
+        lib_file = await on_disk_file_factory(filename="bad:name?.gcode.3mf")
+
+        response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
+        assert response.status_code == 400
+        assert response.json()["detail"]["errors"][0]["file_id"] == lib_file.id
 
 
 
 
 class TestLibraryZipExtractAPI:
 class TestLibraryZipExtractAPI:

+ 60 - 38
backend/tests/integration/test_makerworld_apikey_auth.py

@@ -29,6 +29,12 @@ from backend.app.core.auth import generate_api_key
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.user import User
 from backend.app.models.user import User
+from backend.app.services.model_providers.base import (
+    ProviderDownload,
+    ProviderDownloadInfo,
+    ProviderResolvedModel,
+    ProviderResourceRef,
+)
 
 
 
 
 async def _setup_auth_with_admin(client: AsyncClient) -> str:
 async def _setup_auth_with_admin(client: AsyncClient) -> str:
@@ -104,6 +110,20 @@ def _fake_service(**stubs):
     return svc
     return svc
 
 
 
 
+def _download_info(
+    model_id: int = 1400373,
+    profile_id: int = 298919107,
+    name: str = "cube.3mf",
+) -> ProviderDownloadInfo:
+    """What ``service.get_download`` hands the route — signed URL + raw
+    upstream name + enriched ref (sub_id carries the resolved profile)."""
+    return ProviderDownloadInfo(
+        ref=ProviderResourceRef(source_type="makerworld", external_id=str(model_id), sub_id=str(profile_id)),
+        url="https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
+        suggested_filename=name,
+    )
+
+
 class TestStatusEndpoint:
 class TestStatusEndpoint:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -161,9 +181,13 @@ class TestResolveEndpoint:
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         key = await _make_key(db_session, owner=admin, name="resolve-cloud")
         key = await _make_key(db_session, owner=admin, name="resolve-cloud")
 
 
-        design = {"id": 1400373, "modelId": "US2bb73b106683e5", "title": "Cube", "instances": []}
-        instances = {"total": 0, "hits": []}
-        svc = _fake_service(get_design=design, get_design_instances=instances)
+        svc = _fake_service(
+            resolve=ProviderResolvedModel(
+                ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
+                design={"id": 1400373, "title": "Cube"},
+                instances=[],
+            )
+        )
         build = AsyncMock(return_value=svc)
         build = AsyncMock(return_value=svc)
 
 
         with patch("backend.app.api.routes.makerworld._build_service", build):
         with patch("backend.app.api.routes.makerworld._build_service", build):
@@ -173,14 +197,22 @@ class TestResolveEndpoint:
                 headers={"X-API-Key": key},
                 headers={"X-API-Key": key},
             )
             )
         assert resp.status_code == 200, resp.text
         assert resp.status_code == 200, resp.text
-        # _build_service receives (db, user); the user arg must be the owning admin.
-        # Without the fix it'd be None (the API-key dep value).
+        # _build_service receives (db, provider, current_user, api_key_cloud_owner).
+        # Identity resolution lives in the provider now: for an API-keyed
+        # call current_user is None by design and the key's owner must arrive
+        # via api_key_cloud_owner — without the fix it'd be dropped entirely.
         assert build.await_count == 1
         assert build.await_count == 1
-        passed_user = (
-            build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
+        jwt_user = (
+            build.await_args.args[2] if len(build.await_args.args) > 2 else build.await_args.kwargs.get("current_user")
         )
         )
-        assert passed_user is not None, "resolve_url must pass the API-key owner, not None"
-        assert passed_user.id == admin.id
+        key_owner = (
+            build.await_args.args[3]
+            if len(build.await_args.args) > 3
+            else build.await_args.kwargs.get("api_key_cloud_owner")
+        )
+        assert jwt_user is None, "API-keyed callers present no JWT user"
+        assert key_owner is not None, "resolve_url must forward the API-key owner to the provider"
+        assert key_owner.id == admin.id
 
 
 
 
 class TestImportEndpoint:
 class TestImportEndpoint:
@@ -195,23 +227,12 @@ class TestImportEndpoint:
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         key = await _make_key(db_session, owner=admin, name="import-cloud")
         key = await _make_key(db_session, owner=admin, name="import-cloud")
 
 
-        design = {
-            "id": 1400373,
-            "modelId": "US2bb73b106683e5",
-            "title": "Cube",
-            "instances": [{"profileId": 298919107, "title": "default"}],
-        }
-        manifest = {
-            "name": "cube.3mf",
-            "url": "https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
-        }
-        # 3MF download returns (bytes, filename). The bytes don't have to be a
-        # valid zip — save_3mf_bytes_to_library stores them as-is and the
-        # downstream thumbnail extractor swallows errors.
+        # The 3MF bytes don't have to be a valid zip —
+        # save_3mf_bytes_to_library stores them as-is and the downstream
+        # thumbnail extractor swallows errors.
         svc = _fake_service(
         svc = _fake_service(
-            get_design=design,
-            get_profile_download=manifest,
-            download_3mf=(b"PK\x03\x04fake-3mf-bytes", "cube.3mf"),
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=b"PK\x03\x04fake-3mf-bytes", filename="cube.3mf"),
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
@@ -249,16 +270,9 @@ class TestImportEndpoint:
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         key = await _make_key(db_session, owner=admin, name="import-no-cloud", can_access_cloud=False)
         key = await _make_key(db_session, owner=admin, name="import-no-cloud", can_access_cloud=False)
 
 
-        design = {
-            "id": 1400373,
-            "modelId": "US2bb73b106683e5",
-            "instances": [{"profileId": 298919107}],
-        }
-        manifest = {"name": "cube.3mf", "url": "https://makerworld.bblmw.com/x.3mf"}
         svc = _fake_service(
         svc = _fake_service(
-            get_design=design,
-            get_profile_download=manifest,
-            download_3mf=(b"PK\x03\x04fake", "cube.3mf"),
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=b"PK\x03\x04fake", filename="cube.3mf"),
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)) as build:
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)) as build:
@@ -270,11 +284,19 @@ class TestImportEndpoint:
         assert resp.status_code == 200, resp.text
         assert resp.status_code == 200, resp.text
         body = resp.json()
         body = resp.json()
 
 
-        # _build_service got None — same as before the PR for non-cloud keys.
-        passed_user = (
-            build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
+        # Both identity slots are None — same as before the PR for non-cloud
+        # keys: no JWT user, and the cloud-scope fence keeps the key's owner
+        # back, so the provider builds an anonymous service.
+        jwt_user = (
+            build.await_args.args[2] if len(build.await_args.args) > 2 else build.await_args.kwargs.get("current_user")
+        )
+        key_owner = (
+            build.await_args.args[3]
+            if len(build.await_args.args) > 3
+            else build.await_args.kwargs.get("api_key_cloud_owner")
         )
         )
-        assert passed_user is None
+        assert jwt_user is None
+        assert key_owner is None
 
 
         # And owner_id is NULL because the cloud-scope fence said no.
         # And owner_id is NULL because the cloud-scope fence said no.
         result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
         result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))

+ 180 - 0
backend/tests/integration/test_makerworld_permission_gate.py

@@ -0,0 +1,180 @@
+"""The /makerworld/* permission gate with auth enabled.
+
+The gate moved out of the route signature and into the handler: the provider
+that a request actually uses comes from the body (``source_type`` on import,
+the pasted URL on resolve), and FastAPI resolves dependencies before the body
+exists, so a dependency could only ever name one provider's permission. What
+must not change is the enforcement itself, so these pin the outcomes rather
+than the wiring: anonymous callers are still refused before the body is read,
+and a signed-in user without the permission still gets a 403.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.model_providers.base import (
+    ProviderDownload,
+    ProviderDownloadInfo,
+    ProviderResolvedModel,
+    ProviderResourceRef,
+)
+
+
+async def _setup_auth_with_admin(client: AsyncClient) -> str:
+    await client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "mwadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await client.post("/api/v1/auth/login", json={"username": "mwadmin", "password": "AdminPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+async def _make_user(client: AsyncClient, admin_jwt: str, *, username: str, permissions: list[str]) -> str:
+    """Create a user in a fresh group holding exactly *permissions*."""
+    group = await client.post(
+        "/api/v1/groups/",
+        headers={"Authorization": f"Bearer {admin_jwt}"},
+        json={"name": f"grp_{username}", "permissions": permissions},
+    )
+    assert group.status_code in (200, 201), group.text
+    created = await client.post(
+        "/api/v1/users/",
+        headers={"Authorization": f"Bearer {admin_jwt}"},
+        json={"username": username, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+def _fake_service(**stubs):
+    svc = AsyncMock()
+    svc.close = AsyncMock()
+    for name, value in stubs.items():
+        setattr(svc, name, AsyncMock(return_value=value))
+    return svc
+
+
+def _import_service():
+    return _fake_service(
+        get_download=ProviderDownloadInfo(
+            ref=ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="298919107"),
+            url="https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
+            suggested_filename="cube.3mf",
+        ),
+        download=ProviderDownload(file_bytes=b"PK\x03\x04fake-3mf-bytes", filename="cube.3mf"),
+    )
+
+
+class TestAnonymousIsRefusedFirst:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_anonymous_import_is_401(self, async_client: AsyncClient):
+        await _setup_auth_with_admin(async_client)
+        resp = await async_client.post("/api/v1/makerworld/import", json={"model_id": 1400373})
+        assert resp.status_code == 401, resp.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_anonymous_resolve_is_401(self, async_client: AsyncClient):
+        await _setup_auth_with_admin(async_client)
+        resp = await async_client.post(
+            "/api/v1/makerworld/resolve",
+            json={"url": "https://makerworld.com/en/models/1400373"},
+        )
+        assert resp.status_code == 401, resp.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_anonymous_with_a_malformed_body_is_still_401_not_422(self, async_client: AsyncClient):
+        """The permission moved into the handler, but authentication stayed a
+        route dependency precisely so an unauthenticated caller cannot probe
+        the request schema through validation errors."""
+        await _setup_auth_with_admin(async_client)
+        resp = await async_client.post("/api/v1/makerworld/import", json={"nonsense": True})
+        assert resp.status_code == 401, resp.text
+
+
+class TestPermissionStillBites:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_view_only_user_cannot_import(self, async_client: AsyncClient):
+        admin = await _setup_auth_with_admin(async_client)
+        jwt = await _make_user(async_client, admin, username="mwviewer", permissions=["makerworld:view"])
+
+        with patch(
+            "backend.app.api.routes.makerworld._build_service",
+            AsyncMock(return_value=_import_service()),
+        ):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373},
+                headers={"Authorization": f"Bearer {jwt}"},
+            )
+        assert resp.status_code == 403, resp.text
+        assert "makerworld:import" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_user_without_view_cannot_resolve(self, async_client: AsyncClient):
+        admin = await _setup_auth_with_admin(async_client)
+        jwt = await _make_user(async_client, admin, username="mwnoview", permissions=["printers:read"])
+
+        resp = await async_client.post(
+            "/api/v1/makerworld/resolve",
+            json={"url": "https://makerworld.com/en/models/1400373"},
+            headers={"Authorization": f"Bearer {jwt}"},
+        )
+        assert resp.status_code == 403, resp.text
+        assert "makerworld:view" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_user_holding_the_permission_gets_through(self, async_client: AsyncClient):
+        admin = await _setup_auth_with_admin(async_client)
+        jwt = await _make_user(
+            async_client,
+            admin,
+            username="mwimporter",
+            permissions=["makerworld:view", "makerworld:import"],
+        )
+
+        with patch(
+            "backend.app.api.routes.makerworld._build_service",
+            AsyncMock(return_value=_import_service()),
+        ):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373},
+                headers={"Authorization": f"Bearer {jwt}"},
+            )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["was_existing"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resolve_passes_for_a_viewer(self, async_client: AsyncClient):
+        admin = await _setup_auth_with_admin(async_client)
+        jwt = await _make_user(async_client, admin, username="mwviewer2", permissions=["makerworld:view"])
+
+        svc = _fake_service(
+            resolve=ProviderResolvedModel(
+                ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
+                design={"id": 1400373},
+                instances=[],
+            )
+        )
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/resolve",
+                json={"url": "https://makerworld.com/en/models/1400373"},
+                headers={"Authorization": f"Bearer {jwt}"},
+            )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["model_id"] == 1400373

+ 425 - 0
backend/tests/integration/test_media_token_3025.py

@@ -0,0 +1,425 @@
+"""Integration tests for the media token (#3025).
+
+Thirteen non-camera media routes -- library and archive thumbnails, plate
+previews, timelapses, print photos, QR codes, project covers, link icons --
+were gated by the *camera stream* token. That had two consequences, and these
+tests pin both fixes:
+
+1. ``camera:view`` was a prerequisite for every image in the app. A user given
+   library access to their own job folder saw broken thumbnails until they were
+   also handed the live feed of the room the printer is in.
+2. A camera stream token records no principal, so those routes had no identity
+   to scope by and returned any row to any holder.
+
+The media token is the replacement: minted behind plain authentication, and
+identified, so each route applies the same permission and ownership rules as
+its header-authenticated siblings.
+"""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+# library:read_own + archives:read_own, and deliberately NOT camera:view --
+# the reporter's exact group in #3025.
+NO_CAMERA_PERMISSIONS = [
+    "library:read_own",
+    "library:upload",
+    "archives:read_own",
+    "projects:read",
+    "external_links:read",
+    "printers:read",
+]
+
+
+async def _admin_token(async_client: AsyncClient, suffix: str) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": f"mediaadmin{suffix}",
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": f"mediaadmin{suffix}", "password": "AdminPass1!"},
+    )
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+async def _make_user(
+    async_client: AsyncClient,
+    admin_jwt: str,
+    *,
+    username: str,
+    permissions: list[str],
+) -> tuple[str, int]:
+    """Create a user in a fresh group holding exactly *permissions*."""
+    group = await async_client.post(
+        "/api/v1/groups/",
+        headers={"Authorization": f"Bearer {admin_jwt}"},
+        json={"name": f"grp_{username}", "permissions": permissions},
+    )
+    assert group.status_code in (200, 201), group.text
+    created = await async_client.post(
+        "/api/v1/users/",
+        headers={"Authorization": f"Bearer {admin_jwt}"},
+        json={
+            "username": username,
+            "password": "UserPass1!",
+            "group_ids": [group.json()["id"]],
+        },
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": "UserPass1!"},
+    )
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"], created.json()["id"]
+
+
+async def _mint_media_token(async_client: AsyncClient, jwt: str) -> str:
+    response = await async_client.post(
+        "/api/v1/auth/media-token",
+        headers={"Authorization": f"Bearer {jwt}"},
+    )
+    assert response.status_code == 200, response.text
+    return response.json()["token"]
+
+
+async def _mint_camera_token(async_client: AsyncClient, jwt: str) -> str:
+    response = await async_client.post(
+        "/api/v1/printers/camera/stream-token",
+        headers={"Authorization": f"Bearer {jwt}"},
+    )
+    assert response.status_code == 200, response.text
+    return response.json()["token"]
+
+
+# The routes resolve thumbnails relative to ``settings.base_dir``, so the
+# fixtures have to write there rather than into tmp_path. Keep them in one
+# subdirectory and delete it after every test so a run leaves the tree clean.
+_THUMB_DIR = "test_thumbs_3025"
+
+
+@pytest.fixture(autouse=True)
+def _clean_thumbs():
+    from backend.app.core.config import settings
+
+    yield
+    shutil.rmtree(Path(settings.base_dir) / _THUMB_DIR, ignore_errors=True)
+
+
+async def _library_file(db_session, owner_id: int | None, name: str) -> int:
+    """Insert a library row with a real thumbnail on disk."""
+    from backend.app.core.config import settings
+    from backend.app.models.library import LibraryFile
+
+    thumb = Path(settings.base_dir) / _THUMB_DIR / f"{name}.png"
+    thumb.parent.mkdir(parents=True, exist_ok=True)
+    thumb.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
+
+    row = LibraryFile(
+        filename=f"{name}.3mf",
+        file_path=f"library/files/{name}.3mf",
+        thumbnail_path=f"{_THUMB_DIR}/{thumb.name}",
+        file_type="3mf",
+        file_size=1234,
+        created_by_id=owner_id,
+    )
+    db_session.add(row)
+    await db_session.commit()
+    await db_session.refresh(row)
+    return row.id
+
+
+class TestTheUserWhoCouldNotSeeTheirOwnThumbnails:
+    """The reported fault: camera:view was load-bearing for every image."""
+
+    async def test_a_user_without_camera_view_can_mint_a_media_token(self, async_client: AsyncClient):
+        admin = await _admin_token(async_client, "_mint")
+        jwt, _ = await _make_user(async_client, admin, username="nocamera_mint", permissions=NO_CAMERA_PERMISSIONS)
+        response = await async_client.post("/api/v1/auth/media-token", headers={"Authorization": f"Bearer {jwt}"})
+        assert response.status_code == 200, response.text
+        assert response.json()["token"]
+
+    async def test_the_camera_token_is_still_out_of_reach_for_them(self, async_client: AsyncClient):
+        """The permission split is real, not cosmetic: the media token does not
+        smuggle in camera access, and minting a camera token still costs
+        camera:view."""
+        admin = await _admin_token(async_client, "_nocam")
+        jwt, _ = await _make_user(async_client, admin, username="nocamera_still", permissions=NO_CAMERA_PERMISSIONS)
+        response = await async_client.post(
+            "/api/v1/printers/camera/stream-token", headers={"Authorization": f"Bearer {jwt}"}
+        )
+        assert response.status_code == 403
+
+    async def test_they_can_load_their_own_library_thumbnail(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_own")
+        jwt, user_id = await _make_user(async_client, admin, username="nocamera_own", permissions=NO_CAMERA_PERMISSIONS)
+        file_id = await _library_file(db_session, user_id, "own")
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
+        assert response.status_code == 200, response.text
+        assert response.content.startswith(b"\x89PNG")
+
+
+class TestTheBoundaryBetweenTheTwoTokens:
+    """Neither token is accepted where the other belongs."""
+
+    async def test_a_camera_stream_token_is_refused_on_a_media_route(self, async_client: AsyncClient, db_session):
+        """The inverse of verify_camwall_token's rule. A camera-stream token is
+        anonymous, so honouring it here would reinstate the unowned read."""
+        admin = await _admin_token(async_client, "_xcam")
+        file_id = await _library_file(db_session, None, "xcam")
+        camera_token = await _mint_camera_token(async_client, admin)
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={camera_token}")
+        assert response.status_code == 401
+
+    async def test_a_media_token_is_refused_on_the_live_camera(self, async_client: AsyncClient):
+        admin = await _admin_token(async_client, "_xmedia")
+        media_token = await _mint_media_token(async_client, admin)
+
+        response = await async_client.get(f"/api/v1/printers/1/camera/snapshot?token={media_token}")
+        assert response.status_code == 401
+
+    async def test_no_token_at_all_is_refused(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client, "_notok")
+        file_id = await _library_file(db_session, None, "notok")
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail")
+        assert response.status_code == 401
+
+    async def test_a_garbage_token_is_refused(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client, "_garbage")
+        file_id = await _library_file(db_session, None, "garbage")
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token=not-a-real-token")
+        assert response.status_code == 401
+
+
+class TestWhoseRowsAMediaTokenCanRead:
+    """The unreported half: the old guard had no principal, so it had nothing
+    to scope by. These fail against the camera-token implementation."""
+
+    async def test_it_cannot_read_another_users_library_thumbnail(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_cross")
+        _, alice_id = await _make_user(async_client, admin, username="alice_lib", permissions=NO_CAMERA_PERMISSIONS)
+        bob_jwt, _ = await _make_user(async_client, admin, username="bob_lib", permissions=NO_CAMERA_PERMISSIONS)
+        alice_file = await _library_file(db_session, alice_id, "alice")
+        bob_token = await _mint_media_token(async_client, bob_jwt)
+
+        response = await async_client.get(f"/api/v1/library/files/{alice_file}/thumbnail?token={bob_token}")
+        # 404 rather than 403 -- the same id-enumeration-proof answer
+        # _ensure_library_file_visible gives on every other library route.
+        assert response.status_code == 404
+
+    async def test_an_ownerless_file_needs_read_all(self, async_client: AsyncClient, db_session):
+        """Fail-closed, matching _ensure_library_file_visible."""
+        admin = await _admin_token(async_client, "_orphan")
+        jwt, _ = await _make_user(async_client, admin, username="orphan_reader", permissions=NO_CAMERA_PERMISSIONS)
+        file_id = await _library_file(db_session, None, "orphan")
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
+        assert response.status_code == 404
+
+    async def test_an_admin_with_read_all_still_sees_everything(self, async_client: AsyncClient, db_session):
+        """The gate must not over-correct into breaking legitimate access."""
+        admin = await _admin_token(async_client, "_readall")
+        _, alice_id = await _make_user(async_client, admin, username="alice_readall", permissions=NO_CAMERA_PERMISSIONS)
+        alice_file = await _library_file(db_session, alice_id, "readall")
+        admin_token = await _mint_media_token(async_client, admin)
+
+        response = await async_client.get(f"/api/v1/library/files/{alice_file}/thumbnail?token={admin_token}")
+        assert response.status_code == 200
+
+
+class TestWhatTheTokenStillRequires:
+    """A media token is authentication, not authorisation -- each route keeps
+    asking for the permission its resource is governed by."""
+
+    async def test_a_user_without_library_permission_is_refused(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_noperm")
+        jwt, user_id = await _make_user(async_client, admin, username="noperm_user", permissions=["printers:read"])
+        file_id = await _library_file(db_session, user_id, "noperm")
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
+        assert response.status_code == 403
+
+    async def test_a_deactivated_users_token_stops_working(self, async_client: AsyncClient, db_session):
+        """The token outlives the session it was minted in, so the principal is
+        re-resolved on every request rather than trusted from mint time."""
+        admin = await _admin_token(async_client, "_deact")
+        jwt, user_id = await _make_user(async_client, admin, username="deact_user", permissions=NO_CAMERA_PERMISSIONS)
+        file_id = await _library_file(db_session, user_id, "deact")
+        token = await _mint_media_token(async_client, jwt)
+        assert (await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")).status_code == 200
+
+        deactivate = await async_client.patch(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {admin}"},
+            json={"is_active": False},
+        )
+        assert deactivate.status_code == 200, deactivate.text
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
+        assert response.status_code == 401
+
+
+class TestTheHeaderPathStillWorks:
+    """A media route is reachable with ordinary credentials too, so a fetch()
+    or an API-keyed integration does not need a token at all."""
+
+    async def test_a_bearer_jwt_reaches_a_media_route_without_any_token(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_bearer")
+        jwt, user_id = await _make_user(async_client, admin, username="bearer_user", permissions=NO_CAMERA_PERMISSIONS)
+        file_id = await _library_file(db_session, user_id, "bearer")
+
+        response = await async_client.get(
+            f"/api/v1/library/files/{file_id}/thumbnail",
+            headers={"Authorization": f"Bearer {jwt}"},
+        )
+        assert response.status_code == 200
+
+    async def test_the_header_path_is_ownership_scoped_too(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_bearerx")
+        _, alice_id = await _make_user(async_client, admin, username="alice_bearer", permissions=NO_CAMERA_PERMISSIONS)
+        bob_jwt, _ = await _make_user(async_client, admin, username="bob_bearer", permissions=NO_CAMERA_PERMISSIONS)
+        alice_file = await _library_file(db_session, alice_id, "alicebearer")
+
+        response = await async_client.get(
+            f"/api/v1/library/files/{alice_file}/thumbnail",
+            headers={"Authorization": f"Bearer {bob_jwt}"},
+        )
+        assert response.status_code == 404
+
+
+class TestAuthDisabled:
+    async def test_media_routes_stay_open_when_auth_is_off(self, async_client: AsyncClient, db_session):
+        """No setup call -- auth is off, and the routes must not start
+        demanding a token that an unauthenticated install cannot mint."""
+        file_id = await _library_file(db_session, None, "authoff")
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail")
+        assert response.status_code == 200
+
+
+async def _archive(db_session, owner_id: int | None, name: str) -> int:
+    """Insert an archive with a real thumbnail and timelapse on disk."""
+    from backend.app.core.config import settings
+    from backend.app.models.archive import PrintArchive
+
+    base = Path(settings.base_dir) / _THUMB_DIR
+    base.mkdir(parents=True, exist_ok=True)
+    (base / f"{name}_thumb.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
+    (base / f"{name}_tl.mp4").write_bytes(b"\x00\x00\x00 ftypisom" + b"0" * 32)
+
+    row = PrintArchive(
+        filename=f"{name}.3mf",
+        file_path=f"archives/{name}.3mf",
+        file_size=1234,
+        thumbnail_path=f"{_THUMB_DIR}/{name}_thumb.png",
+        timelapse_path=f"{_THUMB_DIR}/{name}_tl.mp4",
+        created_by_id=owner_id,
+    )
+    db_session.add(row)
+    await db_session.commit()
+    await db_session.refresh(row)
+    return row.id
+
+
+class TestTheArchiveMediaRoutes:
+    """The seven archive routes are where the sensitive content lives -- a
+    timelapse and the finish photos are a video of someone's room. They are
+    covered separately from library because the existing integration suite runs
+    with auth disabled, so nothing else exercises them with auth on."""
+
+    async def test_an_owner_can_load_their_archive_thumbnail(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_arcown")
+        jwt, uid = await _make_user(async_client, admin, username="arc_owner", permissions=NO_CAMERA_PERMISSIONS)
+        archive_id = await _archive(db_session, uid, "arcown")
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/archives/{archive_id}/thumbnail?token={token}")
+        assert response.status_code == 200, response.text
+
+    async def test_another_user_cannot_load_that_thumbnail(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_arcx")
+        _, alice_id = await _make_user(async_client, admin, username="alice_arc", permissions=NO_CAMERA_PERMISSIONS)
+        bob_jwt, _ = await _make_user(async_client, admin, username="bob_arc", permissions=NO_CAMERA_PERMISSIONS)
+        archive_id = await _archive(db_session, alice_id, "arcx")
+        bob_token = await _mint_media_token(async_client, bob_jwt)
+
+        response = await async_client.get(f"/api/v1/archives/{archive_id}/thumbnail?token={bob_token}")
+        assert response.status_code == 404
+
+    async def test_another_user_cannot_load_that_timelapse(self, async_client: AsyncClient, db_session):
+        """The one that matters most: a timelapse is footage of the room the
+        printer is in."""
+        admin = await _admin_token(async_client, "_arctl")
+        _, alice_id = await _make_user(async_client, admin, username="alice_tl", permissions=NO_CAMERA_PERMISSIONS)
+        bob_jwt, _ = await _make_user(async_client, admin, username="bob_tl", permissions=NO_CAMERA_PERMISSIONS)
+        archive_id = await _archive(db_session, alice_id, "arctl")
+        bob_token = await _mint_media_token(async_client, bob_jwt)
+
+        assert (await async_client.get(f"/api/v1/archives/{archive_id}/timelapse?token={bob_token}")).status_code == 404
+
+    async def test_a_camera_token_reaches_no_archive_media(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_arccam")
+        archive_id = await _archive(db_session, None, "arccam")
+        camera_token = await _mint_camera_token(async_client, admin)
+
+        for path in ("thumbnail", "timelapse", "plate-preview", "qrcode"):
+            response = await async_client.get(f"/api/v1/archives/{archive_id}/{path}?token={camera_token}")
+            assert response.status_code == 401, f"{path} accepted a camera token: {response.status_code}"
+
+
+class TestTheFlatPermissionMediaRoutes:
+    """printers/{id}/cover, external-links/{id}/icon and projects/{id}/cover-image
+    have no per-row owner, so they gate on the resource's read permission."""
+
+    async def test_the_link_icon_needs_external_links_read(self, async_client: AsyncClient):
+        admin = await _admin_token(async_client, "_icon")
+        jwt, _ = await _make_user(async_client, admin, username="icon_user", permissions=["printers:read"])
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/external-links/1/icon?token={token}")
+        assert response.status_code == 403
+
+    async def test_the_link_icon_is_reachable_with_that_permission(self, async_client: AsyncClient):
+        admin = await _admin_token(async_client, "_icon2")
+        jwt, _ = await _make_user(async_client, admin, username="icon_user2", permissions=NO_CAMERA_PERMISSIONS)
+        token = await _mint_media_token(async_client, jwt)
+
+        # 404 because no such link exists -- the point is that it is not 401/403.
+        response = await async_client.get(f"/api/v1/external-links/1/icon?token={token}")
+        assert response.status_code == 404
+
+    async def test_the_printer_cover_needs_printers_read(self, async_client: AsyncClient):
+        admin = await _admin_token(async_client, "_cover")
+        jwt, _ = await _make_user(async_client, admin, username="cover_user", permissions=["external_links:read"])
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/printers/1/cover?token={token}")
+        assert response.status_code == 403
+
+    async def test_the_project_cover_needs_projects_read(self, async_client: AsyncClient):
+        admin = await _admin_token(async_client, "_pcover")
+        jwt, _ = await _make_user(async_client, admin, username="pcover_user", permissions=["printers:read"])
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/projects/1/cover-image?token={token}")
+        assert response.status_code == 403

+ 95 - 0
backend/tests/integration/test_ownership_permissions.py

@@ -1907,3 +1907,98 @@ class TestSliceOwnershipPermissions(TestOwnershipPermissionsSetup):
         )
         )
         assert resp.status_code == 404
         assert resp.status_code == 404
         assert resp.json()["detail"] == "File not found"
         assert resp.json()["detail"] == "File not found"
+
+
+class TestLibraryAddToQueueOwnership(TestOwnershipPermissionsSetup):
+    """The bulk add-to-queue path must scope reads the way its siblings do.
+
+    ``POST /library/files/add-to-queue`` resolved its files by raw id and gated
+    only on QUEUE_CREATE, so a READ_OWN operator could queue -- and therefore
+    print, and then hold the archive of -- a file a direct GET on the same id
+    answers 404 for. Same shape as the slice path above.
+
+    An invisible row is dropped before the loop, so it reports as the plain
+    "File not found" an unknown id gets: the response must not say which ids
+    exist. With nothing added the route now answers 400, so the assertions read
+    the reasons out of ``detail``.
+    """
+
+    @pytest.fixture
+    async def library_file_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            _counter[0] += 1
+            defaults = {
+                "filename": f"queue_src_{_counter[0]}.gcode.3mf",
+                "file_path": f"library/queue_src_{_counter[0]}.gcode.3mf",
+                "file_type": "3mf",
+                "file_size": 1024,
+            }
+            defaults.update(kwargs)
+            row = LibraryFile(**defaults)
+            db_session.add(row)
+            await db_session.commit()
+            await db_session.refresh(row)
+            return row
+
+        return _create_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_queue_others_library_file(
+        self, async_client: AsyncClient, auth_setup, library_file_factory
+    ):
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+        resp = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"file_ids": [file.id]},
+        )
+        assert resp.status_code == 400
+        errors = resp.json()["detail"]["errors"]
+        assert [e["error"] for e in errors] == ["File not found"]
+        # Indistinguishable from an id that was never there.
+        assert errors[0]["filename"] == "(not found)"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_queue_own_library_file(
+        self, async_client: AsyncClient, auth_setup, library_file_factory
+    ):
+        """Control: the gate lets the owner through to the on-disk check."""
+        file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
+        resp = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"file_ids": [file.id]},
+        )
+        assert resp.status_code == 400
+        errors = resp.json()["detail"]["errors"]
+        assert [e["error"] for e in errors] == ["File not found on disk"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ownerless_file_needs_read_all(self, async_client: AsyncClient, auth_setup, library_file_factory):
+        """A row with no owner is not everyone's row -- fail closed.
+
+        Matches _ensure_library_file_visible, which the read routes use.
+        """
+        file = await library_file_factory(created_by_id=None)
+        resp = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"file_ids": [file.id]},
+        )
+        assert resp.status_code == 400
+        assert resp.json()["detail"]["errors"][0]["error"] == "File not found"
+
+        admin = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            json={"file_ids": [file.id]},
+        )
+        # READ_ALL sees it and reaches the on-disk check.
+        assert admin.json()["detail"]["errors"][0]["error"] == "File not found on disk"

+ 30 - 0
backend/tests/integration/test_print_queue_api.py

@@ -636,6 +636,36 @@ class TestPrintQueueAPI:
         result = response.json()
         result = response.json()
         assert result["ams_mapping"] == [5, -1, 2, -1]
         assert result["ams_mapping"] == [5, -1, 2, -1]
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_keeps_overrides_on_a_specific_printer_job(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """An override picked for "Any P2S" survives the dialog's switch to one
+        P2S (#3133). The row must keep it: when the dialog could not resolve
+        every tray, the scheduler recomputes the mapping at dispatch, and without
+        the override it would match the 3MF's colour again. It used to be
+        dropped whenever the item had no target model.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "filament_overrides": [{"slot_id": 1, "type": "PLA", "color": "#F5F5DC"}],
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["filament_overrides"] == [{"slot_id": 1, "type": "PLA", "color": "#F5F5DC"}]
+        # The type list gates which printer of a model may take the job; a job
+        # for one printer has no such choice left to make.
+        from backend.app.models.print_queue import PrintQueueItem
+
+        row = await db_session.get(PrintQueueItem, result["id"])
+        assert row.required_filament_types is None
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_queue_response_flags_saved_mapping_only_for_its_own_printer(
     async def test_queue_response_flags_saved_mapping_only_for_its_own_printer(

+ 89 - 21
backend/tests/integration/test_printers_api.py

@@ -1708,8 +1708,19 @@ class TestConfigureAMSSlotAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
-        """PFUS* cloud-synced custom preset IDs are sent to the printer."""
+    async def test_configure_pfus_never_reaches_tray_info_idx(self, async_client: AsyncClient, printer_factory):
+        """A PFUS* cloud setting_id is refused as tray_info_idx (#3003).
+
+        The printer's tray_info_idx field is 8 characters. An 18-character PFUS
+        is stored truncated and acknowledged as a success -- measured on the A1
+        in the #3003 bundle, which sent PFUS9ddc938fe3ab8f and read back
+        PFUS9DDC. That leaves the slot holding an id nothing resolves, so the
+        slicer shows "Generic" and the calibration table loses the slot too.
+        A generic for the material is strictly better, and the preset reference
+        survives in setting_id, which does accept a PFUS.
+
+        Reverses the contract #1053 pinned; see the route's own comment.
+        """
         printer = await printer_factory(name="H2D")
         printer = await printer_factory(name="H2D")
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -1738,12 +1749,20 @@ class TestConfigureAMSSlotAPI:
 
 
             assert response.status_code == 200
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+            # No tray to reuse -> generic for the material, never the raw PFUS.
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
+            # The preset reference is not lost: it moves to the field that holds it.
+            assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):
-        """Provided PFUS* preset takes priority over slot's existing preset."""
+    async def test_configure_pfus_falls_back_to_slot_preset(self, async_client: AsyncClient, printer_factory):
+        """With a PFUS refused, the slot's own resolvable preset is reused (#3003).
+
+        The slot already carries P4d64437 -- a local preset id, 8 characters, so
+        the printer can actually store it -- for the same material. That beats a
+        generic, and it is what the slot's calibration is keyed by.
+        """
         printer = await printer_factory(name="H2D")
         printer = await printer_factory(name="H2D")
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -1789,13 +1808,19 @@ class TestConfigureAMSSlotAPI:
 
 
             assert response.status_code == 200
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Provided preset wins over slot's existing one
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+            # Slot's own storable preset wins over both the PFUS and a generic.
+            assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
+            assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_configure_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):
-        """Provided PFUS* preset is used even when slot has a different material."""
+    async def test_configure_pfus_generic_when_slot_material_differs(self, async_client: AsyncClient, printer_factory):
+        """A slot holding a different material is not reused (#3003).
+
+        Slot has generic PETG, the user is configuring PLA. Neither the refused
+        PFUS nor the mismatched slot can supply a filament id, so the generic
+        for the requested material does.
+        """
         printer = await printer_factory(name="H2D")
         printer = await printer_factory(name="H2D")
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -1834,8 +1859,8 @@ class TestConfigureAMSSlotAPI:
 
 
             assert response.status_code == 200
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Provided preset wins — slot's material is irrelevant
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
+            assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -1873,13 +1898,19 @@ class TestConfigureAMSSlotAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):
-        """Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.
-
-        Pins the end-to-end contract the frontend #1053 fix relies on: when the
-        user configures a slot with a custom cloud preset whose cloud detail
-        has filament_id=null, the frontend sends the setting_id in BOTH fields
-        and the backend must not collapse either to a generic GF* ID.
+    async def test_configure_pfus_pair_splits_into_generic_and_setting_id(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """A PFUS sent in BOTH fields is kept only in setting_id (#3003).
+
+        This is the shape the frontend produced when a custom cloud preset's
+        detail had filament_id=null, and what #1053 pinned. The A1 measurement
+        in #3003 showed where it ends up: the printer truncates tray_info_idx
+        to 8 characters, so the slot resolves to nothing and the slicer falls
+        back to "Generic" anyway -- the very outcome #1053 set out to avoid,
+        plus a broken calibration key. Sending the generic deliberately gets
+        the same slicer result honestly and keeps the slot calibratable, and
+        setting_id still carries the user's preset.
         """
         """
         printer = await printer_factory(name="H2D")
         printer = await printer_factory(name="H2D")
 
 
@@ -1910,10 +1941,47 @@ class TestConfigureAMSSlotAPI:
 
 
             assert response.status_code == 200
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUSa8fb76f9733e3c"
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
             assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
             assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
-            # Explicitly assert no generic-collapse happened for this HT slot.
-            assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_pfcn_refused_as_tray_info_idx(self, async_client: AsyncClient, printer_factory):
+        """PFCN* shared / partner presets are refused the same way (#3003, #1648).
+
+        Same 18-character shape as a PFUS, same truncation. Polymaker's
+        "(Custom)" H2D variants are the ones that reach this in the wild.
+        """
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        mock_status = MagicMock()
+        mock_status.raw_data = {"ams": {"ams": []}}
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/0/1/configure",
+                params={
+                    "tray_info_idx": "PFCN2a91c7d0e4b118",
+                    "tray_type": "PETG",
+                    "tray_sub_brands": "Polymaker PETG (Custom)",
+                    "tray_color": "0000FFFF",
+                    "nozzle_temp_min": 220,
+                    "nozzle_temp_max": 260,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
+            assert call_kwargs.kwargs["setting_id"] == "PFCN2a91c7d0e4b118"
 
 
 
 
 class TestSkipObjectsAPI:
 class TestSkipObjectsAPI:

+ 18 - 14
backend/tests/integration/test_projects_api.py

@@ -220,13 +220,18 @@ class TestProjectUrlAndCoverImage:
         assert response.status_code == 400
         assert response.status_code == 400
 
 
     @pytest.mark.integration
     @pytest.mark.integration
-    def test_cover_image_get_uses_stream_token_gate(self):
-        """Regression guard: GET /projects/{id}/cover-image MUST be gated by
-        ``RequireCameraStreamTokenIfAuthEnabled`` (accepts ``?token=…`` query
-        string) rather than by the bearer-token gate, because browsers can't
-        attach an ``Authorization`` header to ``<img src>`` requests. Swapping
-        back to the bearer gate would silently 401 every cover image when auth
-        is enabled."""
+    def test_cover_image_get_uses_query_token_gate(self):
+        """Regression guard: GET /projects/{id}/cover-image MUST be gated by a
+        dependency that accepts ``?token=…`` in the query string rather than by
+        a header-only bearer gate, because browsers can't attach an
+        ``Authorization`` header to ``<img src>`` requests. Swapping to a
+        header-only gate would silently 401 every cover image when auth is
+        enabled.
+
+        The token type changed in #3025 -- the route took the camera-stream
+        token until then, which made ``camera:view`` a prerequisite for seeing
+        a project cover -- so this pins the media gate. What it is really
+        asserting is unchanged: the credential has to fit in a URL."""
         from fastapi.routing import APIRoute
         from fastapi.routing import APIRoute
 
 
         from backend.app.api.routes.projects import router
         from backend.app.api.routes.projects import router
@@ -242,21 +247,20 @@ class TestProjectUrlAndCoverImage:
 
 
         assert cover_get is not None, "GET cover-image route missing"
         assert cover_get is not None, "GET cover-image route missing"
 
 
-        # The route's dependant tree includes a Depends(require_camera_stream_token_if_auth_enabled())
+        # The route's dependant tree includes a Depends(require_media_token_permission(...))
         # — its `call` is the inner check function returned by that factory.
         # — its `call` is the inner check function returned by that factory.
         # Walk the dependant tree and assert one of the dependencies came from
         # Walk the dependant tree and assert one of the dependencies came from
-        # the stream-token factory, NOT from require_permission_if_auth_enabled.
-        from backend.app.core.auth import (
-            require_camera_stream_token_if_auth_enabled,
-        )
+        # the media-token factory, NOT from require_permission_if_auth_enabled.
+        from backend.app.core.auth import require_media_token_permission
+        from backend.app.core.permissions import Permission
 
 
         # The factory returns a fresh closure each call; the most reliable
         # The factory returns a fresh closure each call; the most reliable
         # signature is the qualified name of the function in the closure chain.
         # signature is the qualified name of the function in the closure chain.
-        expected_qualname = require_camera_stream_token_if_auth_enabled().__qualname__
+        expected_qualname = require_media_token_permission(Permission.PROJECTS_READ).__qualname__
 
 
         gate_qualnames = [dep.call.__qualname__ for dep in cover_get.dependant.dependencies if dep.call]
         gate_qualnames = [dep.call.__qualname__ for dep in cover_get.dependant.dependencies if dep.call]
         assert expected_qualname in gate_qualnames, (
         assert expected_qualname in gate_qualnames, (
-            f"GET cover-image route is not gated by RequireCameraStreamTokenIfAuthEnabled. Found: {gate_qualnames}"
+            f"GET cover-image route is not gated by a media-token dependency. Found: {gate_qualnames}"
         )
         )
 
 
 
 

+ 25 - 0
backend/tests/integration/test_queue_variants_api.py

@@ -295,3 +295,28 @@ class TestQueueWithVariants:
         assert len(item_ids) == 3
         assert len(item_ids) == 3
         total = (await db_session.execute(select(PrintQueueVariant))).scalars().all()
         total = (await db_session.execute(select(PrintQueueVariant))).scalars().all()
         assert len(total) == 6
         assert len(total) == 6
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_quantity_batch_is_named_after_the_first_candidate(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        """A cross-model job has no archive_id and no library_file_id -- the
+        candidates are the files -- so the batch name has to come from one of
+        them or every such order reads "Batch" in the Batches tab (#3101)."""
+        from backend.app.models.print_batch import PrintBatch
+
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S", filename="bloom.gcode.3mf")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id, quantity=4)
+        assert r.status_code == 200
+
+        batch = (await db_session.execute(select(PrintBatch))).scalars().one()
+        assert batch.name == "bloom ×4"
+        # Both stay null: the row cannot name one source without disowning the
+        # others, and every consumer derives progress from the items instead.
+        assert batch.archive_id is None
+        assert batch.library_file_id is None

+ 1 - 1
backend/tests/integration/test_scheduler_budget_reservation.py

@@ -202,7 +202,7 @@ async def test_cost_center_without_budget_is_unlimited_regardless_of_wallet_bala
         center = await db.get(CostCenter, billing_dispatch_case.ids.cost_center_id)
         center = await db.get(CostCenter, billing_dispatch_case.ids.cost_center_id)
         center.monthly_budget = None
         center.monthly_budget = None
         center.total_budget = None
         center.total_budget = None
-        wallet = UserWallet(user_id=user.id, balance=-100.0, currency="EUR")
+        wallet = UserWallet(user_id=user.id, balance=-100.0)
         db.add(wallet)
         db.add(wallet)
         await db.commit()
         await db.commit()
 
 

+ 25 - 4
backend/tests/integration/test_security.py

@@ -1912,6 +1912,27 @@ class TestEncryptionRoundtrip:
 # ============================================================================
 # ============================================================================
 
 
 
 
+def _minimal_sqlite_backup() -> bytes:
+    """A real, if empty, SQLite database to stand in for a backup's bambuddy.db.
+
+    Restore now refuses a bambuddy.db it cannot open, and refuses it before it
+    stops services or overwrites the MFA key file -- a corrupt or truncated
+    backup used to be found only by the Postgres import, which by then had
+    dropped every table in the live database. These tests are about the key
+    handling around the swap, so they need a file that opens; what is in it does
+    not matter.
+    """
+    import sqlite3
+
+    conn = sqlite3.connect(":memory:")
+    try:
+        conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)")
+        conn.commit()
+        return conn.serialize()
+    finally:
+        conn.close()
+
+
 class TestBackupKeyFiles:
 class TestBackupKeyFiles:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -1980,7 +2001,7 @@ class TestBackupKeyFiles:
         # Build a minimal ZIP with a stub DB and the key file.
         # Build a minimal ZIP with a stub DB and the key file.
         buf = io.BytesIO()
         buf = io.BytesIO()
         with zipfile.ZipFile(buf, "w") as zf:
         with zipfile.ZipFile(buf, "w") as zf:
-            zf.writestr("bambuddy.db", b"SQLite format 3")
+            zf.writestr("bambuddy.db", _minimal_sqlite_backup())
             zf.writestr(".mfa_encryption_key", "test-restored-key")
             zf.writestr(".mfa_encryption_key", "test-restored-key")
         buf.seek(0)
         buf.seek(0)
 
 
@@ -2020,7 +2041,7 @@ class TestBackupKeyFiles:
 
 
         buf = io.BytesIO()
         buf = io.BytesIO()
         with zipfile.ZipFile(buf, "w") as zf:
         with zipfile.ZipFile(buf, "w") as zf:
-            zf.writestr("bambuddy.db", b"SQLite format 3")
+            zf.writestr("bambuddy.db", _minimal_sqlite_backup())
             # Intentionally no .mfa_encryption_key entry.
             # Intentionally no .mfa_encryption_key entry.
         buf.seek(0)
         buf.seek(0)
 
 
@@ -2061,7 +2082,7 @@ class TestBackupKeyFiles:
         # Build ZIP with a key file that we will fail to write to DATA_DIR.
         # Build ZIP with a key file that we will fail to write to DATA_DIR.
         buf = io.BytesIO()
         buf = io.BytesIO()
         with zipfile.ZipFile(buf, "w") as zf:
         with zipfile.ZipFile(buf, "w") as zf:
-            zf.writestr("bambuddy.db", b"SQLite format 3 backup data")
+            zf.writestr("bambuddy.db", _minimal_sqlite_backup())
             zf.writestr(".mfa_encryption_key", "backup-key-content")
             zf.writestr(".mfa_encryption_key", "backup-key-content")
         buf.seek(0)
         buf.seek(0)
 
 
@@ -2141,7 +2162,7 @@ class TestBackupKeyFiles:
         assert new_key != old_key
         assert new_key != old_key
         buf = io.BytesIO()
         buf = io.BytesIO()
         with zipfile.ZipFile(buf, "w") as zf:
         with zipfile.ZipFile(buf, "w") as zf:
-            zf.writestr("bambuddy.db", b"SQLite format 3 backup data")
+            zf.writestr("bambuddy.db", _minimal_sqlite_backup())
             zf.writestr(".mfa_encryption_key", new_key)
             zf.writestr(".mfa_encryption_key", new_key)
         buf.seek(0)
         buf.seek(0)
 
 

+ 169 - 0
backend/tests/integration/test_settings_ui_flags_3023.py

@@ -0,0 +1,169 @@
+"""The app shell can read install configuration without settings:read (#3023).
+
+Reporter @lonix: a user holding `cost_centers:read_own` never saw the Finance
+entry in the sidebar. The permission map was right and the route guard was
+right -- navigating to /finance directly worked and showed their balance. What
+hid it was an extra condition, `billing_enabled !== true`, read from
+GET /settings, which requires SETTINGS_READ. A non-admin gets 403 there, so the
+value arrived undefined and the entry was hidden from exactly the users the
+permission exists to serve.
+
+SETTINGS_READ cannot be the price of knowing whether billing is on: it also
+grants sight of the SMTP, LDAP and MQTT credentials. Hence /settings/ui-flags,
+which asks only that the caller be signed in.
+
+It is deliberately not more fields on /settings/ui-preferences. That endpoint is
+served to anyone at all, on the recorded grounds that its contents are "public
+defaults that ship with the app" (test_route_auth_coverage.py), and its field
+set is pinned by a test written to stop exactly this kind of addition. These
+fields are not defaults -- they say how this deployment is configured -- so the
+last test here pins that they did not leak into it.
+"""
+
+import secrets
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.settings import Settings
+
+FLAGS_URL = "/api/v1/settings/ui-flags"
+_FIXTURE_PW = "Aa1!" + secrets.token_urlsafe(12)  # pragma: allowlist secret
+
+
+async def _setup_admin(async_client: AsyncClient, username: str) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": username, "admin_password": _FIXTURE_PW},
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": _FIXTURE_PW},
+    )
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+async def _create_operator(
+    async_client: AsyncClient,
+    admin_token: str,
+    *,
+    username: str,
+    permissions: list[str],
+) -> str:
+    """A non-admin holding exactly `permissions` -- never settings:read."""
+    headers = {"Authorization": f"Bearer {admin_token}"}
+    grp = await async_client.post(
+        "/api/v1/groups/",
+        headers=headers,
+        json={"name": f"ui_flags_test_{username}", "permissions": permissions},
+    )
+    assert grp.status_code == 201, grp.text
+    user = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={
+            "username": username,
+            "password": _FIXTURE_PW,
+            "role": "user",
+            "group_ids": [grp.json()["id"]],
+        },
+    )
+    assert user.status_code == 201, user.text
+    assert user.json()["is_admin"] is False
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": _FIXTURE_PW},
+    )
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+@pytest.mark.integration
+class TestTheUserTheEndpointExistsFor:
+    """A non-admin with cost_centers:read_own and nothing else."""
+
+    @pytest.mark.asyncio
+    async def test_they_can_read_the_flags(self, async_client: AsyncClient):
+        admin = await _setup_admin(async_client, "flagadmin1")
+        op = await _create_operator(async_client, admin, username="flagop1", permissions=["cost_centers:read_own"])
+
+        resp = await async_client.get(FLAGS_URL, headers={"Authorization": f"Bearer {op}"})
+        assert resp.status_code == 200, resp.text
+        assert "billing_enabled" in resp.json()
+
+    @pytest.mark.asyncio
+    async def test_they_still_cannot_read_settings(self, async_client: AsyncClient):
+        """The fix must not have widened SETTINGS_READ to get there."""
+        admin = await _setup_admin(async_client, "flagadmin2")
+        op = await _create_operator(async_client, admin, username="flagop2", permissions=["cost_centers:read_own"])
+
+        resp = await async_client.get("/api/v1/settings/", headers={"Authorization": f"Bearer {op}"})
+        assert resp.status_code == 403, resp.text
+
+    @pytest.mark.asyncio
+    async def test_billing_enabled_carries_the_configured_value(self, async_client: AsyncClient, db_session):
+        """The whole point: the sidebar tests this for `true`, so it has to be
+        the real value and a real bool, not a truthy string."""
+        admin = await _setup_admin(async_client, "flagadmin3")
+        op = await _create_operator(async_client, admin, username="flagop3", permissions=["cost_centers:read_own"])
+        db_session.add(Settings(key="billing_enabled", value="true"))
+        await db_session.commit()
+
+        resp = await async_client.get(FLAGS_URL, headers={"Authorization": f"Bearer {op}"})
+        assert resp.json()["billing_enabled"] is True
+
+
+@pytest.mark.integration
+class TestTheBoundaryItDraws:
+    """Signed in is required; settings:read is not."""
+
+    @pytest.mark.asyncio
+    async def test_an_anonymous_caller_is_refused_when_auth_is_on(self, async_client: AsyncClient):
+        """This is the reason it is a separate endpoint rather than four more
+        fields on the public one."""
+        await _setup_admin(async_client, "flagadmin4")
+
+        resp = await async_client.get(FLAGS_URL)
+        assert resp.status_code in (401, 403), resp.text
+
+    @pytest.mark.asyncio
+    async def test_it_answers_when_auth_is_switched_off(self, async_client: AsyncClient):
+        """An install with no auth has no user to authenticate, and the shell
+        still has to render. require_auth_if_enabled returns None there."""
+        resp = await async_client.get(FLAGS_URL)
+        assert resp.status_code == 200, resp.text
+
+
+@pytest.mark.integration
+class TestWhatItExposes:
+    @pytest.mark.asyncio
+    async def test_the_field_set_is_exactly_these_four(self, async_client: AsyncClient):
+        """Pinned like the /ui-preferences set: anything added here is readable
+        by every signed-in user, so adding one should require editing this."""
+        resp = await async_client.get(FLAGS_URL)
+        assert set(resp.json().keys()) == {
+            "billing_enabled",
+            "user_notifications_enabled",
+            "currency",
+            "check_updates",
+        }
+
+    @pytest.mark.asyncio
+    async def test_no_credential_ever_appears(self, async_client: AsyncClient, db_session):
+        for i, key in enumerate(
+            ("smtp_password", "ldap_bind_password", "mqtt_password", "ha_token", "prometheus_token")
+        ):
+            db_session.add(Settings(key=key, value=f"SECRET_VALUE_{i}_DO_NOT_LEAK"))
+        await db_session.commit()
+
+        body = (await async_client.get(FLAGS_URL)).text
+        assert "DO_NOT_LEAK" not in body
+
+    @pytest.mark.asyncio
+    async def test_the_public_endpoint_did_not_gain_them(self, async_client: AsyncClient):
+        """These describe the deployment, not app defaults, so they must not
+        have been added to the endpoint that serves anyone at all."""
+        public = (await async_client.get("/api/v1/settings/ui-preferences")).json()
+        assert "billing_enabled" not in public
+        assert "user_notifications_enabled" not in public

+ 289 - 0
backend/tests/integration/test_slicer_token_reuse_3029.py

@@ -0,0 +1,289 @@
+"""Integration tests for reusable slicer download tokens (#3029).
+
+The "Slice" action hands a URL to a *separate process* -- Bambu Studio or
+OrcaSlicer, launched through a protocol handler that cannot carry an
+``Authorization`` header. Until this fix the token in that URL was consumed by
+the first request that reached the endpoint, which made the handoff dependent
+on the slicer fetching the URL exactly once. Nothing guarantees that: Bambu
+Studio's downloader retries three times after a failed attempt, transfers get
+resumed, on-access scanners fetch. Whichever party arrived first won, and the
+slicer was handed a 403.
+
+So the three protocol-handler downloads now accept their token for the rest of
+its five-minute TTL. Everything else about the token is unchanged, and these
+tests pin the difference in both directions: the second fetch works, and the
+token is still refused for the wrong resource, after expiry, and when unknown.
+
+The two *browser* downloads that share the same primitive stay one-shot, and
+are pinned here too -- the prepared printer bundle is deleted once streamed, so
+reuse there could only ever mean a 404 with a misleading cause.
+
+The second half covers a fault found while checking the first: the auth
+middleware matches ``PUBLIC_API_PATTERNS`` by substring, and the source-3MF
+route's segment is ``source-dl`` -- which does not contain ``/dl/``. With auth
+enabled the middleware rejected the slicer's header-less request before the
+route's own token check ever ran.
+"""
+
+from __future__ import annotations
+
+import shutil
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+# Same reasoning as #3025's fixtures: the routes resolve paths relative to
+# ``settings.base_dir``, which under test is the project root, so everything
+# goes in one subdirectory that is removed after each test.
+_FILE_DIR = "test_files_3029"
+
+
+@pytest.fixture(autouse=True)
+def _clean_files():
+    from backend.app.core.config import settings
+
+    yield
+    shutil.rmtree(Path(settings.base_dir) / _FILE_DIR, ignore_errors=True)
+
+
+def _write(name: str, body: bytes) -> str:
+    """Write a file under the scratch dir and return its base_dir-relative path."""
+    from backend.app.core.config import settings
+
+    path = Path(settings.base_dir) / _FILE_DIR / name
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_bytes(body)
+    return f"{_FILE_DIR}/{name}"
+
+
+async def _library_file(db_session, name: str, body: bytes = b"solid test\nendsolid test\n") -> int:
+    from backend.app.models.library import LibraryFile
+
+    row = LibraryFile(
+        filename=f"{name}.stl",
+        file_path=_write(f"{name}.stl", body),
+        file_type="stl",
+        file_size=len(body),
+    )
+    db_session.add(row)
+    await db_session.commit()
+    await db_session.refresh(row)
+    return row.id
+
+
+async def _archive(db_session, name: str, *, with_source: bool = False) -> int:
+    from backend.app.models.archive import PrintArchive
+
+    row = PrintArchive(
+        filename=f"{name}.3mf",
+        file_path=_write(f"{name}.3mf", b"PK\x03\x04sliced"),
+        file_size=13,
+        source_3mf_path=_write(f"{name}_source.3mf", b"PK\x03\x04source") if with_source else None,
+    )
+    db_session.add(row)
+    await db_session.commit()
+    await db_session.refresh(row)
+    return row.id
+
+
+async def _stored_token(resource_type: str, resource_id: int, *, expires_in_minutes: int = 5) -> str:
+    """Insert a slicer token directly, so expiry can be set to the past."""
+    import secrets
+
+    from backend.app.core.database import async_session
+    from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
+
+    token = secrets.token_urlsafe(24)
+    async with async_session() as db:
+        db.add(
+            AuthEphemeralToken(
+                token=token,
+                token_type=TokenType.SLICER_DOWNLOAD,
+                nonce=f"{resource_type}:{resource_id}",
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=expires_in_minutes),
+            )
+        )
+        await db.commit()
+    return token
+
+
+class TestTheSlicerThatFetchesTwice:
+    """The reported fault: the second fetch of the same URL got a 403, and the
+    slicer wrote that JSON body out as the model."""
+
+    async def test_a_library_download_survives_a_second_fetch(self, async_client: AsyncClient, db_session):
+        file_id = await _library_file(db_session, "reused")
+        minted = await async_client.post(f"/api/v1/library/files/{file_id}/slicer-token")
+        assert minted.status_code == 200, minted.text
+        token = minted.json()["token"]
+
+        url = f"/api/v1/library/files/{file_id}/dl/{token}/reused.stl"
+        first = await async_client.get(url)
+        assert first.status_code == 200, first.text
+        assert first.content.startswith(b"solid test")
+
+        second = await async_client.get(url)
+        assert second.status_code == 200, second.text
+        assert second.content == first.content
+
+        third = await async_client.get(url)
+        assert third.status_code == 200
+
+    async def test_an_archive_download_survives_a_second_fetch(self, async_client: AsyncClient, db_session):
+        archive_id = await _archive(db_session, "arc_reused")
+        minted = await async_client.post(f"/api/v1/archives/{archive_id}/slicer-token")
+        assert minted.status_code == 200, minted.text
+        token = minted.json()["token"]
+
+        url = f"/api/v1/archives/{archive_id}/dl/{token}/arc_reused.3mf"
+        assert (await async_client.get(url)).status_code == 200
+        assert (await async_client.get(url)).status_code == 200
+
+    async def test_a_source_3mf_download_survives_a_second_fetch(self, async_client: AsyncClient, db_session):
+        archive_id = await _archive(db_session, "src_reused", with_source=True)
+        minted = await async_client.post(f"/api/v1/archives/{archive_id}/source-slicer-token")
+        assert minted.status_code == 200, minted.text
+        token = minted.json()["token"]
+
+        url = f"/api/v1/archives/{archive_id}/source-dl/{token}/src_reused.3mf"
+        first = await async_client.get(url)
+        assert first.status_code == 200, first.text
+        assert (await async_client.get(url)).status_code == 200
+
+
+class TestWhatTheReusableTokenStillRefuses:
+    """Reuse is the only thing that changed. Resource binding and expiry are
+    what make these URLs safe to hand out, so each is checked explicitly."""
+
+    async def test_it_is_still_bound_to_one_file(self, async_client: AsyncClient, db_session):
+        mine = await _library_file(db_session, "bound_mine")
+        theirs = await _library_file(db_session, "bound_theirs")
+        token = (await async_client.post(f"/api/v1/library/files/{mine}/slicer-token")).json()["token"]
+
+        wrong = await async_client.get(f"/api/v1/library/files/{theirs}/dl/{token}/bound_theirs.stl")
+        assert wrong.status_code == 403
+
+        # And the rejected attempt must not have burned the token for its own file.
+        right = await async_client.get(f"/api/v1/library/files/{mine}/dl/{token}/bound_mine.stl")
+        assert right.status_code == 200
+
+    async def test_an_archive_token_does_not_open_the_source_3mf(self, async_client: AsyncClient, db_session):
+        """The two archive downloads are separate resource keys on the same id."""
+        archive_id = await _archive(db_session, "cross_key", with_source=True)
+        token = (await async_client.post(f"/api/v1/archives/{archive_id}/slicer-token")).json()["token"]
+
+        crossed = await async_client.get(f"/api/v1/archives/{archive_id}/source-dl/{token}/cross_key.3mf")
+        assert crossed.status_code == 403
+
+    async def test_an_expired_token_is_refused(self, async_client: AsyncClient, db_session):
+        file_id = await _library_file(db_session, "stale")
+        token = await _stored_token("library", file_id, expires_in_minutes=-1)
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/dl/{token}/stale.stl")
+        assert response.status_code == 403
+
+    async def test_an_unknown_token_is_refused(self, async_client: AsyncClient, db_session):
+        file_id = await _library_file(db_session, "unknown")
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/dl/not-a-token/unknown.stl")
+        assert response.status_code == 403
+
+
+class TestTheOneShotDownloadsStayOneShot:
+    """Reuse was granted per endpoint, not to the primitive. The two browser
+    downloads keep consuming their token, and the default is still to consume
+    -- a new caller has to ask for reuse deliberately."""
+
+    async def test_the_primitive_still_consumes_by_default(self, async_client: AsyncClient, db_session):
+        from backend.app.core.auth import verify_slicer_download_token
+
+        token = await _stored_token("printer-files", 7)
+        assert await verify_slicer_download_token(token, "printer-files", 7) is True
+        assert await verify_slicer_download_token(token, "printer-files", 7) is False
+
+    async def test_a_reusable_check_does_not_consume(self, async_client: AsyncClient, db_session):
+        from backend.app.core.auth import verify_slicer_download_token
+
+        token = await _stored_token("library", 7)
+        assert await verify_slicer_download_token(token, "library", 7, single_use=False) is True
+        assert await verify_slicer_download_token(token, "library", 7, single_use=False) is True
+        # ...and a consuming check on the same row still works, so the row is
+        # not a different kind of token -- only the redemption differs.
+        assert await verify_slicer_download_token(token, "library", 7) is True
+        assert await verify_slicer_download_token(token, "library", 7, single_use=False) is False
+
+    async def test_the_archive_timelapse_download_is_still_single_use(self, async_client: AsyncClient, db_session):
+        from backend.app.models.archive import PrintArchive
+
+        row = PrintArchive(
+            filename="tl.3mf",
+            file_path=_write("tl.3mf", b"PK\x03\x04"),
+            file_size=4,
+            timelapse_path=_write("tl.mp4", b"\x00\x00\x00 ftypisom"),
+        )
+        db_session.add(row)
+        await db_session.commit()
+        await db_session.refresh(row)
+
+        token = (await async_client.post(f"/api/v1/archives/{row.id}/media-download-token")).json()["token"]
+        url = f"/api/v1/archives/{row.id}/media/dl/{token}/tl.mp4"
+        assert (await async_client.get(url)).status_code == 200
+        assert (await async_client.get(url)).status_code == 403
+
+
+class TestTheSourceDownloadReachesItsHandler:
+    """``PUBLIC_API_PATTERNS`` is matched with ``in path``, and ``source-dl/``
+    does not contain ``/dl/``. With auth enabled the middleware answered 401
+    before the route's token check ran, so "Open source 3MF in slicer" could
+    never work -- the slicer has no header to send."""
+
+    async def test_the_pattern_list_covers_the_source_route(self):
+        from backend.app.main import PUBLIC_API_PATTERNS
+
+        path = "/api/v1/archives/5/source-dl/tok/model.3mf"
+        assert not any(p in path for p in ["/dl/"]), "guard: /dl/ must not cover source-dl"
+        assert any(p in path for p in PUBLIC_API_PATTERNS)
+
+    async def test_the_source_download_works_with_auth_enabled(self, async_client: AsyncClient, db_session):
+        setup = await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": True, "admin_username": "slicer3029", "admin_password": "AdminPass1!"},
+        )
+        assert setup.status_code in (200, 201), setup.text
+        login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "slicer3029", "password": "AdminPass1!"},
+        )
+        assert login.status_code == 200, login.text
+        jwt = login.json()["access_token"]
+
+        archive_id = await _archive(db_session, "authed_source", with_source=True)
+        minted = await async_client.post(
+            f"/api/v1/archives/{archive_id}/source-slicer-token",
+            headers={"Authorization": f"Bearer {jwt}"},
+        )
+        assert minted.status_code == 200, minted.text
+        token = minted.json()["token"]
+
+        # No Authorization header -- exactly what the protocol handler sends.
+        response = await async_client.get(f"/api/v1/archives/{archive_id}/source-dl/{token}/authed_source.3mf")
+        assert response.status_code == 200, response.text
+        assert response.content == b"PK\x03\x04source"
+
+    async def test_a_bad_token_is_refused_by_the_handler_not_the_middleware(
+        self, async_client: AsyncClient, db_session
+    ):
+        """403, not 401: the middleware stepping aside must not make the route
+        public, and the distinction is what proves the handler ran."""
+        setup = await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": True, "admin_username": "slicer3029b", "admin_password": "AdminPass1!"},
+        )
+        assert setup.status_code in (200, 201), setup.text
+        archive_id = await _archive(db_session, "refused_source", with_source=True)
+
+        response = await async_client.get(f"/api/v1/archives/{archive_id}/source-dl/nope/refused_source.3mf")
+        assert response.status_code == 403

+ 136 - 0
backend/tests/integration/test_spoolbuddy_color_name_3090.py

@@ -0,0 +1,136 @@
+"""The kiosk has to be told when a spool's colour name is only a stand-in (#3090).
+
+SpoolBuddy showed "Unknown color" for spools Bambuddy names perfectly well. The
+name is not in the spool record: Bambu's RFID tags frequently carry none, and
+Spoolman has no colour-name field at all, so the frontend resolves the swatch's
+hex against the colour catalog instead. The kiosk was reading the raw column.
+
+That is a frontend fix, except for one thing the frontend cannot work out on
+its own. In Spoolman mode ``_map_spoolman_spool`` puts the spool's *subtype*
+in ``color_name`` when nothing is stored, so the kiosk receives "Silk+" — a
+plausible-looking string that would beat the catalog if it were taken at face
+value. ``color_name_is_synthesized`` is how the backend already marks that,
+and these tests pin it onto the tag-matched broadcast, which is the one place
+the kiosk learns about a scanned spool.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.settings import Settings
+
+SPOOLBUDDY_API = "/api/v1/spoolbuddy"
+
+
+@pytest.fixture
+async def spoolman_enabled(db_session: AsyncSession):
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://spoolman.local:7912"))
+    await db_session.commit()
+
+
+@pytest.fixture
+async def spoolman_disabled(db_session: AsyncSession):
+    db_session.add(Settings(key="spoolman_enabled", value="false"))
+    await db_session.commit()
+
+
+def _spoolman_spool_without_a_colour_name() -> dict:
+    """A Silk+ roll as Spoolman holds it: a swatch, and nowhere to put a name."""
+    return {
+        "id": 38,
+        "filament": {
+            "material": "PLA",
+            "name": "PLA Silk+",
+            "color_hex": "D02727",  # Candy Red, in the colour catalog
+            "weight": 1000.0,
+            "spool_weight": 250.0,
+            "vendor": {"name": "Bambu Lab"},
+        },
+        "used_weight": 0.0,
+        "archived": False,
+        "registered": "2024-01-01T00:00:00Z",
+    }
+
+
+def _mock_spoolman_client(spool: dict) -> MagicMock:
+    client = MagicMock()
+    client.base_url = "http://spoolman.local:7912"
+    client.get_spools = AsyncMock(return_value=[spool])
+    client.find_spool_by_tag = AsyncMock(return_value=spool)
+    client.merge_spool_extra = AsyncMock(return_value={})
+    return client
+
+
+async def _scan(async_client: AsyncClient) -> dict:
+    """Scan a tag and return the broadcast the kiosk would receive."""
+    with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+        mock_ws.broadcast = AsyncMock()
+        resp = await async_client.post(
+            f"{SPOOLBUDDY_API}/nfc/tag-scanned",
+            json={
+                "device_id": "sb-test",
+                "tag_uid": "AABB1122334455FF",
+                "tray_uuid": "DEADBEEFDEADBEEFDEADBEEFDEADBEEF",
+            },
+        )
+    assert resp.status_code == 200
+    mock_ws.broadcast.assert_called_once()
+    return mock_ws.broadcast.call_args[0][0]
+
+
+class TestTheScanBroadcastSaysWhereTheNameCameFrom:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_spoolman_spool_is_marked_as_having_no_real_name(self, async_client: AsyncClient, spoolman_enabled):
+        """Spoolman keeps no colour name, so what arrives is the subtype."""
+        spool = _spoolman_spool_without_a_colour_name()
+        client = _mock_spoolman_client(spool)
+        with (
+            patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
+            patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=client)),
+        ):
+            msg = await _scan(async_client)
+
+        assert msg["type"] == "spoolbuddy_tag_matched"
+        # The stand-in is still sent — it is the only thing there, and a kiosk
+        # that cannot resolve the hex should show something.
+        assert msg["spool"]["color_name"] == "Silk+"
+        assert msg["spool"]["color_name_is_synthesized"] is True, (
+            'without this the kiosk shows "Silk+" where the catalog knows the colour'
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_local_spool_is_never_marked_synthesized(self, async_client: AsyncClient, spoolman_disabled):
+        """Local inventory stores what the user or their tag set, or nothing.
+
+        A name that is present is a real one, and an absent one must stay
+        absent rather than acquiring a stand-in — the kiosk resolves the empty
+        case from the swatch, and cannot do that for a name it is told to
+        trust.
+        """
+        spool = MagicMock()
+        spool.id = 38
+        spool.material = "PLA"
+        spool.subtype = "Silk+"
+        spool.color_name = None
+        spool.rgba = "D02727FF"
+        spool.brand = "Bambu Lab"
+        spool.label_weight = 1000
+        spool.core_weight = 250
+        spool.weight_used = 0
+
+        with patch(
+            "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
+            new_callable=AsyncMock,
+            return_value=spool,
+        ):
+            msg = await _scan(async_client)
+
+        assert msg["type"] == "spoolbuddy_tag_matched"
+        assert msg["spool"]["color_name"] is None
+        assert msg["spool"]["color_name_is_synthesized"] is False

+ 113 - 0
backend/tests/integration/test_spoolman_inventory_api.py

@@ -2083,6 +2083,119 @@ class TestLinkTagDuplicate:
         detail = resp.json()["detail"]
         detail = resp.json()["detail"]
         assert "42" in str(detail)
         assert "42" in str(detail)
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_409_is_the_same_structured_detail_as_the_built_in_route(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """#3110: one shape for both inventory modes, not two prose sentences.
+
+        The built-in route said "already linked to another active spool" and
+        named nobody; this one named the spool but only inside a sentence. A
+        client had to parse prose, and a different sentence per mode.
+        """
+        resp = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/99/tag",
+            json={"tray_uuid": "AABBCCDDEEFF0011AABBCCDDEEFF0011"},
+        )
+
+        assert resp.status_code == 409
+        detail = resp.json()["detail"]
+        assert detail["code"] == "tag_already_linked"
+        assert detail["spool_id"] == 42
+        assert detail["field"] == "tray_uuid"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_field_follows_the_precedence_the_tag_itself_uses(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """tray_uuid wins over tag_uid when both are sent, so `field` says so."""
+        mock_spoolman_client.get_all_spools.return_value = [
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 42, "extra": {"tag": '"AABBCCDDEEFF0011"'}}
+        ]
+
+        resp = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/99/tag",
+            json={"tag_uid": "AABBCCDDEEFF0011"},
+        )
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["field"] == "tag_uid"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_duplicate_holders_yield_the_lowest_id(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Spoolman has no unique constraint on extra.tag either.
+
+        Whichever row the scan reached first was an arbitrary answer; the
+        built-in route names the lowest id, so this one does too.
+        """
+        tag = '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'
+        mock_spoolman_client.get_all_spools.return_value = [
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 77, "extra": {"tag": tag}},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 42, "extra": {"tag": tag}},
+        ]
+
+        resp = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/99/tag",
+            json={"tray_uuid": "AABBCCDDEEFF0011AABBCCDDEEFF0011"},
+        )
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["spool_id"] == 42
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_malformed_row_after_the_holder_does_not_sink_the_request(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """extra is free-form and edited outside Bambuddy.
+
+        Naming the lowest id means reading every row, where the old loop
+        stopped at its first match -- so a row whose extra.tag is a JSON null
+        (which .get("tag", "") hands back as None, not the default) sits
+        between the caller and their 409 in a way it never used to.
+        """
+        tag = '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'
+        mock_spoolman_client.get_all_spools.return_value = [
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 42, "extra": {"tag": tag}},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 55, "extra": {"tag": None}},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 56, "extra": {"tag": 12345}},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 57, "extra": None},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 58, "extra": []},
+        ]
+
+        resp = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/99/tag",
+            json={"tray_uuid": "AABBCCDDEEFF0011AABBCCDDEEFF0011"},
+        )
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["spool_id"] == 42
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_malformed_row_is_not_itself_read_as_a_holder(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """A link with no real conflict still succeeds past those rows."""
+        mock_spoolman_client.get_all_spools.return_value = [
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 55, "extra": {"tag": None}},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 56, "extra": {"tag": 12345}},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 57, "extra": None},
+        ]
+
+        resp = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/42/tag",
+            json={"tag_uid": "AABBCCDD112233"},
+        )
+
+        assert resp.status_code == 200
+        mock_spoolman_client.update_spool_full.assert_called_once()
+
 
 
 class TestSpoolmanInventoryUpdateCoreWeight:
 class TestSpoolmanInventoryUpdateCoreWeight:
     """core_weight is accepted for schema parity but not persisted — any value should be accepted."""
     """core_weight is accepted for schema parity but not persisted — any value should be accepted."""

+ 45 - 0
backend/tests/unit/services/test_ams_slot_presence.py

@@ -0,0 +1,45 @@
+"""Unit tests for the firmware presence bit helper.
+
+#3084: a slot can read ``exists=True, state=9`` — the bit says a spool is in
+it, the state says the opposite — because ``apply_tray_exist_bits`` writes the
+9 itself and never takes it back. Everything downstream has to know which of
+the two to believe, and this helper is the single place that says so.
+"""
+
+from backend.app.services.ams_slot_presence import spool_present
+
+
+class TestSpoolPresent:
+    def test_the_bit_is_reported_as_it_stands(self):
+        assert spool_present({"id": 0, "exists": True}) is True
+        assert spool_present({"id": 0, "exists": False}) is False
+
+    def test_the_bit_answers_over_a_contradicting_state(self):
+        # The #3084 slot: non-Bambu spool swapped in, so the bit is set, while
+        # the 9 Bambuddy stamped on the slot when it was briefly empty is still
+        # sitting there.
+        assert spool_present({"id": 0, "exists": True, "state": 9}) is True
+        # And the converse — a spool pulled from a slot the firmware last
+        # described as loaded.
+        assert spool_present({"id": 0, "exists": False, "state": 11}) is False
+
+    def test_a_tray_with_no_annotation_answers_nothing(self):
+        # vt_tray entries and the VP bridge's cache carry no presence bit, so
+        # callers have to fall back to their own reading rather than be handed
+        # a guess dressed up as firmware's answer.
+        assert spool_present({"id": 0, "state": 11, "tray_type": "PLA"}) is None
+        assert spool_present({}) is None
+
+    def test_a_non_bool_exists_is_not_a_presence_bit(self):
+        # Only apply_tray_exist_bits writes this key, and it writes a bool.
+        # Anything else reached the dict some other way and is not firmware's
+        # answer — 0 and "" would otherwise read as a confident "empty".
+        assert spool_present({"exists": 0}) is None
+        assert spool_present({"exists": ""}) is None
+        assert spool_present({"exists": "true"}) is None
+        assert spool_present({"exists": None}) is None
+
+    def test_a_missing_tray_answers_nothing(self):
+        assert spool_present(None) is None
+        assert spool_present([]) is None
+        assert spool_present("tray") is None

+ 106 - 0
backend/tests/unit/services/test_bambu_cloud_credentials.py

@@ -0,0 +1,106 @@
+"""Tests for ``services/bambu_cloud_credentials`` — the credential seam.
+
+The read paths are covered indirectly by the cloud-token expiry and
+migration suites; these pin the write path that review blocker 5 hinged on:
+``mark_cloud_token_invalid`` must record a rejection for *both* identity
+shapes, because auth-disabled single-user installs (the default) hold their
+token in global ``Settings`` — ``user_id=None`` is a real, expected input,
+not a degenerate one.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.core.auth import get_password_hash
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services import bambu_cloud_credentials as creds
+from backend.app.services.bambu_cloud_credentials import (
+    CLOUD_TOKEN_INVALID_KEY,
+    mark_cloud_token_invalid,
+)
+
+pytestmark = pytest.mark.asyncio
+
+
+class _SharedSessionCtx:
+    """Route ``mark`` through the fixture's in-memory session: the function
+    normally opens its own session against the configured database, which in
+    tests is a different SQLite than ``db_session``'s in-memory one."""
+
+    def __init__(self, session):
+        self._session = session
+
+    async def __aenter__(self):
+        return self._session
+
+    async def __aexit__(self, *exc):
+        return False
+
+
+@pytest.fixture(autouse=True)
+def shared_session(db_session, monkeypatch):
+    monkeypatch.setattr(creds, "async_session", lambda: _SharedSessionCtx(db_session))
+
+
+async def _make_user(db, username: str = "cred-user") -> User:
+    user = User(
+        username=username,
+        password_hash=get_password_hash("AdminPass1!"),
+        role="admin",
+        is_active=True,
+    )
+    db.add(user)
+    await db.commit()
+    await db.refresh(user)
+    return user
+
+
+async def test_mark_sets_the_per_user_flag(db_session):
+    """user_id set → the rejection lands on that user's column."""
+    user = await _make_user(db_session)
+
+    await mark_cloud_token_invalid(user.id)
+    await db_session.refresh(user)
+
+    assert user.cloud_token_invalid_at is not None
+
+
+async def test_mark_none_writes_the_global_settings_flag(db_session):
+    """user_id=None (auth-disabled install) → the global ``Settings`` row.
+    A second call updates the existing row rather than adding another."""
+    await mark_cloud_token_invalid(None)
+
+    result = await db_session.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+    rows = result.scalars().all()
+    assert len(rows) == 1
+    # Stored value parses as ISO — the status endpoints compare it as a date.
+    datetime.fromisoformat(rows[0].value)
+
+    first_value = rows[0].value
+    await mark_cloud_token_invalid(None)
+    result = await db_session.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+    rows = result.scalars().all()
+    assert len(rows) == 1
+    assert rows[0].value >= first_value
+
+
+async def test_mark_is_best_effort(db_session, monkeypatch):
+    """A bookkeeping failure must never replace the 401 the caller needs to
+    see — the function swallows everything."""
+
+    class _Boom:
+        async def __aenter__(self):
+            raise RuntimeError("db gone")
+
+        async def __aexit__(self, *exc):
+            return False
+
+    monkeypatch.setattr(creds, "async_session", lambda: _Boom())
+
+    # Must not raise.
+    await mark_cloud_token_invalid(None)

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio