maziggy пре 2 дана
родитељ
комит
d80414b518
100 измењених фајлова са 8266 додато и 1406 уклоњено
  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_run_1.3mf
+
+# Python coverage artifacts
+.coverage
+.coverage.*
+htmlcov/

Разлика између датотеке није приказан због своје велике величине
+ 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,
         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,
         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.auth import (
-    RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     check_printer_access,
     current_api_key_if_present,
     probe_permissions_if_auth_enabled,
+    require_media_token_ownership,
     require_ownership_permission,
 )
 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.filament_requirements import annotate_rack_groups
 from backend.app.services.print_storage import (
+    REASON_FTP_TRANSFER_FAILED,
+    REASON_FTPS_COOLOFF,
     REASON_INTERNAL_HISTORY,
     REASON_INTERNAL_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
     known cause was install step 4 ("Store sent files on external storage")
     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).
 
     ``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
     # 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
     # 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
     # 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).
-    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:
             return {"has_fallback": True, "reason": candidate}
     return {"has_fallback": True, "reason": None}
@@ -2291,13 +2322,15 @@ async def download_archive_for_slicer(
 ):
     """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.
     """
     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")
 
     service = ArchiveService(db)
@@ -2320,15 +2353,22 @@ async def download_archive_for_slicer(
 async def get_thumbnail(
     archive_id: int,
     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.
 
-    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)
-    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")
 
     thumb_path = settings.base_dir / archive.thumbnail_path
@@ -2549,15 +2589,22 @@ async def download_archive_media_with_token(
 async def get_timelapse(
     archive_id: int,
     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.
 
-    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)
-    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")
 
     timelapse_path = settings.base_dir / archive.timelapse_path
@@ -3265,16 +3312,21 @@ async def get_photo(
     archive_id: int,
     filename: str,
     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.
 
-    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))
-    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
     # filename that doesn't appear here is by definition not a real photo.
@@ -3353,12 +3405,19 @@ async def get_qrcode(
     request: Request,
     size: int = 200,
     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.
 
-    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:
         import qrcode
         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")
 
     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
     base_url = str(request.base_url).rstrip("/")
@@ -3694,19 +3751,24 @@ async def get_gcode(
 async def get_plate_preview(
     archive_id: int,
     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.
 
     Returns the slicer-generated plate thumbnail which shows the model
     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)
-    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
     if not file_path.is_file():
@@ -4227,16 +4289,21 @@ async def get_plate_thumbnail(
     archive_id: int,
     plate_index: int,
     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.
 
-    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)
-    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
     if not file_path.is_file():
@@ -4678,18 +4745,23 @@ async def get_project_image(
     archive_id: int,
     image_path: str,
     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.
 
-    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
 
     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
     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.
 
-    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
 
-    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")
 
     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_by_email,
     create_access_token,
+    create_media_token,
     create_websocket_token,
     get_current_active_user,
     get_password_hash,
     get_user_by_email,
     get_user_by_username,
     is_jti_revoked,
+    require_auth_if_enabled,
     resolve_apikey_owner,
     resolve_session_max_minutes,
     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:
             # handing another admin's session a Bambu credential is not a
             # 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:
                 cloud_owner = admin_user
@@ -659,6 +659,30 @@ async def mint_websocket_token(
     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)
 async def get_current_user_info(
     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 json
 import logging
-from datetime import datetime, timezone
 from pathlib import Path
 from typing import Literal
 
@@ -23,7 +22,7 @@ from backend.app.core.auth import (
     require_permission_if_auth_enabled,
     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.models.api_key import APIKey
 from backend.app.models.settings import Settings
@@ -50,6 +49,22 @@ from backend.app.services.bambu_cloud import (
     BambuCloudService,
     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
 
 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)])
 
 
-# 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:
     """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.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.database import get_db
 from backend.app.core.permissions import Permission
@@ -239,11 +239,14 @@ async def delete_icon(
 async def get_icon(
     link_id: int,
     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.
 
-    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))
     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,
     is_personal_transaction,
     personal_balance_condition,
+    resolve_configured_currency,
     sync_personal_wallet_balance,
 )
 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:
         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)
     await db.flush()
     await db.refresh(wallet)
@@ -276,24 +277,31 @@ async def _get_cost_center_or_404(db: AsyncSession, cost_center_id: int) -> Cost
     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(
         user_id=wallet.user_id,
         balance=wallet.balance,
-        currency=wallet.currency,
+        currency=currency,
         updated_at=wallet.updated_at,
     )
 
 
 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."""
+    currency = await resolve_configured_currency(db)
     wallet = await db.scalar(select(UserWallet).where(UserWallet.user_id == user_id))
     if wallet is not None:
-        return _to_balance_response(wallet)
+        return _to_balance_response(wallet, currency)
     return WalletBalanceResponse(
         user_id=user_id,
         balance=await calculate_personal_balance(db, user_id),
-        currency="EUR",
+        currency=currency,
         updated_at=None,
     )
 
@@ -386,15 +394,16 @@ async def _create_wallet_adjustment(
     await db.refresh(tx)
 
     # Return appropriate balance based on transaction type
+    currency = await resolve_configured_currency(db)
     if affects_personal_wallet:
         # Personal transaction: return user wallet balance
-        response_balance = _to_balance_response(wallet)
+        response_balance = _to_balance_response(wallet, currency)
     else:
         # Cost-center transaction: return cost-center balance as if it were a wallet
         response_balance = WalletBalanceResponse(
             user_id=target_user_id,
             balance=balance_after,
-            currency=wallet.currency,
+            currency=currency,
             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,
 )
 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 (
     DUPLICATE_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.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 (
     GENERIC_FILAMENT_IDS,
     filament_id_to_setting_id,
@@ -1850,6 +1852,9 @@ async def assign_spool(
     fingerprint_type = None
     current_tray_info_idx = ""
     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)
     if state and state.raw_data:
         if data.ams_id == 255:
@@ -1864,6 +1869,7 @@ async def assign_spool(
                     raw_state = vt.get("state")
                     if isinstance(raw_state, int):
                         tray_state = raw_state
+                    tray_has_spool = spool_present(vt)
                     break
         else:
             ams_data = state.raw_data.get("ams", {})
@@ -1886,6 +1892,7 @@ async def assign_spool(
                 raw_state = tray.get("state")
                 if isinstance(raw_state, int):
                     tray_state = raw_state
+                tray_has_spool = spool_present(tray)
 
     # 3. Upsert assignment (replace if same printer+ams+tray)
     existing = await db.execute(
@@ -1945,7 +1952,30 @@ async def assign_spool(
     # a doomed MQTT push when the firmware has positively confirmed "no
     # spool" — and to keep the on_ams_change replay path as the single
     # 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
     if not slot_is_definitely_empty:
         try:
@@ -2082,7 +2112,12 @@ async def link_tag_to_spool(
     db: AsyncSession = Depends(get_db),
     _: 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))
     spool = result.scalar_one_or_none()
     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.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:
         conflict = await db.execute(
-            select(Spool).where(
+            select(Spool)
+            .where(
                 func.upper(Spool.tag_uid) == normalized_tag_uid,
                 Spool.id != spool_id,
                 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)
         archived_with_tag = await db.execute(
             select(Spool).where(
@@ -2120,14 +2168,17 @@ async def link_tag_to_spool(
 
     if normalized_tray_uuid:
         conflict = await db.execute(
-            select(Spool).where(
+            select(Spool)
+            .where(
                 func.upper(Spool.tray_uuid) == normalized_tray_uuid,
                 Spool.id != spool_id,
                 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(
             select(Spool).where(
                 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."""
 
+import asyncio
 import base64
 import binascii
 import contextlib
@@ -21,8 +22,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 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 (
-    RequireCameraStreamTokenIfAuthEnabled,
+    require_media_token_ownership,
     require_ownership_permission,
     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.library import LibraryFile, LibraryFileTag, LibraryFolder
 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.user import User
 from backend.app.schemas.library import (
@@ -86,6 +90,7 @@ from backend.app.utils.filename import (
     safe_path_component,
     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.threemf_tools import (
     carries_gcode,
@@ -94,6 +99,7 @@ from backend.app.utils.threemf_tools import (
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
+    sanitize_project_settings_sentinels,
     select_plate_gcode_name,
     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.
     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] = []
     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
     result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
     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
     # inherits that project, so the resulting archive counts toward the
     # 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
 
+            # 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)
             max_position += 1
             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,
                 project_id=lib_file.project_id
                 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)
             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()
 
     return AddToQueueResponse(added=added, errors=errors)
@@ -3213,16 +3336,22 @@ async def get_library_file_plate_thumbnail(
     file_id: int,
     plate_index: int,
     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
 
+    user, can_read_all = auth_result
     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
     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:
     """Overwrite ``curr_bed_type`` in a process-profile JSON before forwarding
     to the slicer sidecar.
@@ -4016,13 +4009,14 @@ async def _run_slicer_with_fallback(
     is_3mf = model_filename.lower().endswith(".3mf")
     primary_bytes = model_bytes
     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
         # 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 +
     # inject server-side so the library card has a thumbnail. Best-effort:
     # 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)
 
     # 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
     # 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
-    # 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)
 
     # 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.
 
-    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.
     """
     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")
 
     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(
     file_id: int,
     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)
     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.
 
-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
 ``design/search`` endpoint returns empty results from server-originated
 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
@@ -16,19 +23,20 @@ import logging
 import os
 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.security import HTTPAuthorizationCredentials
 from sqlalchemy import select
 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.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.permissions import Permission
 from backend.app.models.library import LibraryFile, LibraryFolder
@@ -41,74 +49,119 @@ from backend.app.schemas.makerworld import (
     MakerWorldResolveRequest,
     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__)
 
 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))
-    if isinstance(exc, MakerWorldAuthError):
+    if isinstance(exc, ProviderAuthError):
         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.
         return HTTPException(status_code=403, detail=str(exc))
-    if isinstance(exc, MakerWorldNotFoundError):
+    if isinstance(exc, ProviderNotFoundError):
         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=500, detail=f"MakerWorld error: {exc}")
+    return HTTPException(status_code=500, detail=f"Model provider error: {exc}")
 
 
 @router.get("/thumbnail")
@@ -133,10 +186,10 @@ async def proxy_thumbnail(
     URLs are content-addressable (filename contains a hash), so the
     aggressive ``immutable`` cache-control is safe.
     """
-    service = MakerWorldService()
+    service = MakerWorldService(thumbnail_hosts=makerworld_provider.thumbnail_hosts())
     try:
         payload, content_type = await service.fetch_thumbnail(url)
-    except MakerWorldError as exc:
+    except ProviderError as exc:
         raise _map_service_error(exc) from exc
     finally:
         await service.close()
@@ -153,7 +206,7 @@ async def proxy_thumbnail(
 @router.get("/status", response_model=MakerWorldStatus)
 async def get_status(
     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),
 ):
     """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
     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(
-        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(
     body: MakerWorldResolveRequest,
     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),
 ):
     """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"
     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:
-        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
+    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:
-        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
     finally:
         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.
-    model_prefix = _canonical_url(model_id)
     existing_q = await db.execute(
         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),
         )
     )
@@ -262,17 +292,24 @@ async def resolve_url(
     return MakerWorldResolvedModel(
         model_id=model_id,
         profile_id=profile_id,
-        design=design,
-        instances=instances,
+        design=resolved.design,
+        instances=resolved.instances,
         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(
     body: MakerWorldImportRequest,
     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),
 ):
     """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
     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:
         folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == body.folder_id))
         target_folder = folder_q.scalar_one_or_none()
@@ -294,90 +340,78 @@ async def import_instance(
             )
         effective_folder_id: int | None = body.folder_id
     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
-    # (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:
-        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
+    finally:
+        await service.close()
 
-    signed_url = manifest.get("url")
     # Basename-strip any path components from the upstream filename so a
     # malicious response (``name: "../../evil.3mf"``) can't persist a suspect
     # 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.
-    raw_name = manifest.get("name")
+    raw_name = info.suggested_filename
     if isinstance(raw_name, str) and raw_name.strip():
         # MakerWorld emits percent-encoded names (`%20` for spaces, etc.)
         # 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"
     else:
         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
     # path ends in a UUID that's not meaningful to users. Decode the
     # fallback path-tail too — same percent-encoding round-trip applies
     # 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(
         db,
-        file_bytes=file_bytes,
+        file_bytes=download.file_bytes,
         filename=filename,
         folder_id=effective_folder_id,
-        source_type=_SOURCE_TYPE,
+        source_type=provider.source_type,
         source_url=source_url,
         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,
         filename=library_file.filename,
         folder_id=library_file.folder_id,
-        profile_id=profile_id,
+        profile_id=resolved_profile_id,
         was_existing=was_existing,
     )
 
@@ -442,23 +456,27 @@ async def import_instance(
 async def recent_imports(
     limit: int = 10,
     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.
 
     Surfaces files whose ``source_type`` is ``"makerworld"`` so the MakerWorld
     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.
     """
     _ = current_user  # permission gate only
     capped = max(1, min(50, int(limit)))
+
     result = await db.execute(
         LibraryFile.active()
-        .where(LibraryFile.source_type == _SOURCE_TYPE)
+        .where(LibraryFile.source_type == makerworld_provider.source_type)
         .order_by(LibraryFile.created_at.desc())
         .limit(capped)
     )
     rows = result.scalars().all()
+
     return [
         MakerWorldRecentImport(
             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.")
 
     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:
-            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
 
 

+ 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 backend.app.core.auth import (
-    RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    require_media_token_ownership,
     require_ownership_permission,
 )
 from backend.app.core.config import settings
@@ -136,11 +136,19 @@ async def get_print_log(
 async def get_print_log_thumbnail(
     entry_id: int,
     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.
 
-    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
     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
     log list will simply not request this thumbnail again.
     """
+    user, can_read_all = auth_result
     entry = await db.get(PrintLogEntry, entry_id)
     if not entry or not entry.thumbnail_path:
         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
     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)
     required_filament_types = 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:
-        # 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():
             filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
             if filament_types:
                 required_filament_types = json.dumps(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
-    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)
         if plate_overrides:
             filament_overrides_json = json.dumps(plate_overrides)
             # 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})
-            if override_types:
+            if override_types and target_model_norm:
                 # Merge with existing types (overrides may only cover some slots)
                 existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
                 # 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
             else:
                 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 = 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.auth import (
-    RequireCameraStreamTokenIfAuthEnabled,
     RequireOverlayTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePrinterPermissionIfAuthEnabled,
     is_auth_enabled,
+    require_media_token_permission,
+    require_media_token_printer_permission,
 )
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
@@ -87,9 +88,11 @@ from backend.app.services.printer_media import (
     remove_printer_files_zip,
     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.utils.ams_humidity import ams_humidity_percent
 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.http import build_content_disposition, download_error_response, safe_download_filename
 from backend.app.utils.kprofile_lookup import build_slot_k_resolver
@@ -526,13 +529,14 @@ async def get_printer_status(
     ams_exists = False
     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)
 
     # Cached active-cycle drying params (filament + target temp) we sent
@@ -583,22 +587,11 @@ async def get_printer_status(
                         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
             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(
     printer_id: int,
     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.
 
+    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:
         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.
@@ -1970,7 +1969,7 @@ async def get_printer_file_plate_thumbnail(
     printer_id: int,
     plate_index: int,
     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."""
     import io
@@ -2849,10 +2848,49 @@ async def configure_ams_slot(
     if not client:
         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.
     # 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
     #      (non-generic) preset for the same material.
     #   3. Fall back to a generic Bambu filament ID.
@@ -3804,10 +3842,12 @@ async def bed_jog(
     distance: float = Query(
         ...,
         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),
@@ -3817,31 +3857,49 @@ async def bed_jog(
 
     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:
         raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
@@ -3855,14 +3913,10 @@ async def bed_jog(
     if not client:
         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)):
         raise HTTPException(500, "Failed to send bed-jog command")
@@ -3897,9 +3951,9 @@ async def xy_jog(
     if y:
         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"])):
         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 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.database import get_db
 from backend.app.core.permissions import Permission
@@ -1412,13 +1412,19 @@ async def upload_project_cover_image(
 async def get_project_cover_image(
     project_id: int,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    _: User | None = Depends(require_media_token_permission(Permission.PROJECTS_READ)),
 ):
     """Stream the project's cover image (#1155).
 
     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))
     project = result.scalar_one_or_none()
     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.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.permissions import Permission
 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}
 
 
+# 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")
 async def check_ffmpeg(
     _: 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:
                     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
         # bambuddy.db. Without it, encrypted client_secret / TOTP secret rows
         # 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):
     """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
 
+    # 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)
     pg_engine = _create_engine()
 
@@ -917,8 +1105,24 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
                 if not columns:
                     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)
                 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()
 
                 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:
                         val = row[c]
                         if val is None and c in nn_defaults:
@@ -1093,6 +1303,30 @@ async def restore_backup(
         if not backup_db.exists():
             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:
             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.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 (
     _ORCA_TYPE_TO_BAMBU,
     _build_authenticated_service as _build_orca_service,
@@ -43,6 +43,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudError,
     BambuCloudService,
 )
+from backend.app.services.bambu_cloud_credentials import get_stored_token
 from backend.app.services.orca_cloud import (
     OrcaCloudAuthError,
     OrcaCloudError,

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

@@ -421,6 +421,11 @@ async def nfc_tag_scanned(
                             "material": mapped["material"],
                             "subtype": mapped["subtype"],
                             "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"],
                             "brand": mapped["brand"],
                             "label_weight": mapped["label_weight"],
@@ -485,6 +490,9 @@ async def nfc_tag_scanned(
                         "material": spool.material,
                         "subtype": spool.subtype,
                         "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,
                         "brand": spool.brand,
                         "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,
 )
 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.filament_ids import (
     GENERIC_FILAMENT_IDS,
@@ -1154,6 +1155,18 @@ async def sync_spool_weight(
     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")
 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.
 
     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.
+
+    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)
     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):
         # 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():
             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.
         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.smart_plug import SmartPlug
 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 (
     LogEntry,
     collect_sensitive_strings,
@@ -806,6 +806,11 @@ async def _collect_support_info() -> dict:
         },
         "environment": {
             "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)),
             "log_dir": _sanitize_path(str(settings.log_dir)),
             "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 ---
-# 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
 
 
 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)
     expires_at = now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES)
     token = secrets.token_urlsafe(24)
@@ -703,30 +709,49 @@ async def create_slicer_download_token(resource_type: str, resource_id: int) ->
     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.
-    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.
     Previously the token was consumed (committed) even when stored_key != expected_key,
     permanently invalidating it while returning False to the caller.
     """
     expected_key = f"{resource_type}:{resource_id}"
     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:
-        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:
             return False
         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
 # NOT single-use — streams reconnect on errors.  Stored in AuthEphemeralToken
 # (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
 
 
@@ -893,6 +923,89 @@ async def verify_overlay_token(token: str) -> bool:
         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:
     """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
     which cannot send Authorization headers. The frontend obtains a token from
     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:
@@ -2230,3 +2349,135 @@ def require_ownership_permission(
             )
 
     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
 
 # Application version - single source of truth
-APP_VERSION = "1.2.5.5"
+APP_VERSION = "1.2.5.6"
 GITHUB_REPO = "maziggy/bambuddy"
 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,
                 user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
                 balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
-                currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
                 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
             )
             """,
@@ -1366,7 +1365,6 @@ async def _migrate_create_finance_tables(conn) -> None:
                 id SERIAL PRIMARY KEY,
                 user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
                 balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
-                currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
                 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:
     """Add the nullable cost-center link missing from pre-billing archives."""
     await _safe_execute(
@@ -1868,6 +1895,10 @@ async def run_migrations(conn):
     await _migrate_finance_money_to_numeric(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
     try:
         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_scheduler import scheduler as print_scheduler
 from backend.app.services.print_storage import (
+    REASON_FTP_TRANSFER_FAILED,
     REASON_FTPS_COOLOFF,
     external_storage_present,
     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.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.fts_routing import extruder_for_inlet
 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.models.spool import Spool as _Spool
             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
 
             # 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
                     # explicit "empty" signals authoritative over any stale
                     # 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:
                         try:
                             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,
                             )
                             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
                         # assigned spool (e.g. auto-configure changed the tray).
                         # 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.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
 
             # 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
                         # loses the runout segment's usage — the same failure
                         # 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))
                         _clear_unknown_tag_dedup(printer_id, ams_id, tray_id_raw)
                         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.
 _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.
 _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
 
 
-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__)
+    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:
         from backend.app.models.archive import PrintArchive
         from backend.app.models.printer import Printer
 
-        for delay in _FALLBACK_3MF_RETRY_DELAYS_SECONDS:
+        for delay in delays:
             await asyncio.sleep(delay)
 
             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())
     _fallback_3mf_retry_tasks[printer_id] = task
     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,
+        reason,
         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.
         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
         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.
                         cache_3mf_download(printer_id, try_filename, temp_path)
                         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:
                     # 550 — file isn't at this path. Advance to next candidate
                     # without burning the retry budget.
                     logger.debug("3MF not at %s (550), trying next path", remote_path)
                 except Exception as e:
+                    ftp_transfer_failed = True
                     logger.debug("FTP download failed for %s: %s", remote_path, e)
 
             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)
                                 cache_3mf_download(printer_id, fname, temp_path)
                                 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:
                     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
                     temp_path = 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:
             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:
                 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:
                     # Clean up the name (remove extensions, path parts)
                     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"),
                     extra_data={
                         "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,
                     },
                 )
@@ -4465,13 +4596,15 @@ async def on_print_start(printer_id: int, data: dict):
                 except Exception as 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
                     # list this flow just tried, already stripped of any path
                     # (`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,
                         archive_id=fallback_archive.id,
                         filenames=list(possible_names),
+                        reason=no_3mf_reason,
                     )
 
                 # 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)
 _ams_alarm_cooldown: dict[str, datetime] = {}
 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:
@@ -7950,20 +8089,30 @@ async def record_ams_history():
                     for ams_data in raw_data["ams"]:
                         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
                         temperature = None
@@ -7983,7 +8132,12 @@ async def record_ams_history():
                             printer_id=printer.id,
                             ams_id=ams_id,
                             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,
                         )
                         db.add(history)
@@ -8815,7 +8969,7 @@ async def lifespan(app: FastAPI):
     import httpx as _httpx
 
     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,
     )
     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
     # download token in the URL path instead.
     "/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).
     # The nonce itself is the credential: 32-byte random, single-use, ~30s TTL.
     "/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.
 
     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"
@@ -45,7 +52,6 @@ class UserWallet(Base):
     id: Mapped[int] = mapped_column(primary_key=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)
-    currency: Mapped[str] = mapped_column(String(3), default="EUR")
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
     user: Mapped[User] = relationship()

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

@@ -25,7 +25,29 @@ class LibraryFolder(Base):
 
     # Link to project or archive
     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
     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
     cost: float | 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.)
     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."""
 
     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):

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

@@ -42,6 +42,16 @@ class MakerWorldImportRequest(BaseModel):
         ...,
         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(
         default=None,
         description=(

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

@@ -301,9 +301,11 @@ class NtfyConfig(BaseModel):
     event_priorities: dict[str, int] | None = Field(
         default=None,
         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
     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
-    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)
     library_file_id: int | None  # For queue items from library files
     cost_center_id: int | None = None

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

@@ -446,10 +446,14 @@ class PrinterStatus(BaseModel):
 class DiagnosticCheck(BaseModel):
     """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
     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

+ 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.
         self.last_failure: FtpFailure | 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:
         """Check if this is an A1 series printer."""
@@ -656,6 +659,10 @@ class BambuFTPClient:
                 cap_tls_v1_2=profile.cap_tls_v1_2,
             )
             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")
             self._ftp.login("bblp", self.access_code)
             if use_prot_c:
@@ -677,12 +684,12 @@ class BambuFTPClient:
         except ftplib.error_perm as 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._abandon_connection()
+            self._abandon_connection("login rejected")
             return False
         except TimeoutError as e:
             logger.warning("FTP connection timed out to %s: %s", self.ip_address, e)
             self.last_failure = FtpFailure(FtpFailureKind.TIMEOUT, str(e))
-            self._abandon_connection()
+            self._abandon_connection("connect timed out")
             return False
         except ssl.SSLError as e:
             # 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
             # #2780's cleanup was added to stop. Idempotent, so the call that
             # 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.
             # Checked before the deadline below is written, so a live entry here
@@ -743,10 +750,21 @@ class BambuFTPClient:
         except (OSError, ftplib.Error) as e:
             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._abandon_connection()
+            self._abandon_connection("connect failed")
             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.
 
         Every failure path in :meth:`connect` used to clear ``self._ftp`` and
@@ -765,24 +783,50 @@ class BambuFTPClient:
         """
         ftp = self._ftp
         self._ftp = None
+        held = self._held_for()
+        self._connected_at = None
         if ftp is None:
             return
         try:
             ftp.close()
         except (OSError, ftplib.Error, EOFError):
             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):
         """Disconnect from the FTP server."""
         if self._ftp:
+            held = self._held_for()
             try:
                 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
                 # raises, ftplib never reaches its own close and the socket
                 # 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._connected_at = None
 
     def list_files(self, path: str = "/", *, raise_on_error: bool = False) -> list[dict]:
         """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_errors import describe_fault
 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__)
 
@@ -84,6 +86,20 @@ def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | 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) ──────────
 # The A2L reports its 4-slot AMS Lite as physical unit **id 16**, but the
 # firmware is internally inconsistent about it:
@@ -1596,15 +1612,14 @@ class BambuMQTTClient:
         #     reconnect, mixing stale commands into the next dispatch and
         #     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)
         self._stale_reconnecting = True
         self.state.connected = False
@@ -1615,11 +1630,11 @@ class BambuMQTTClient:
     def _reset_client_for_reconnect(self) -> None:
         """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:
             loop = asyncio.get_running_loop()
         except RuntimeError:
@@ -1636,18 +1651,15 @@ class BambuMQTTClient:
         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.
         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
         self._client = 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
         # pre-init). The next initial connect() call from PrinterManager will
         # 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
             # for ~3 s before the firmware reflects the change. Without this
             # 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:
                 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:
@@ -3677,7 +3697,7 @@ class BambuMQTTClient:
         cycle ending at 63 degC with the reading still above the threshold is
         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:
             self._drying_stops_sent.discard(ams_id)
             logger.info(
@@ -4947,8 +4967,10 @@ class BambuMQTTClient:
             except (ValueError, TypeError):
                 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"])
             self.state.timelapse = data["timelapse"] is True
             # Track if timelapse was ever active during this print
@@ -6001,7 +6023,11 @@ class BambuMQTTClient:
                     "vibration_cali": vibration_cali,
                     "layer_inspect": layer_inspect,
                     "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:
                     # 0 = never, 1 = force every print, 2 = auto (run only if the
                     # filament wasn't calibrated recently). #1721 saw stage 8
@@ -6356,14 +6382,32 @@ class BambuMQTTClient:
         return True
 
     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:
+            old_client = self._client
             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._client.loop_stop()
             self._client = None
+            retire_paho_client(old_client, self.serial_number)
             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):
         """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
     "My Printer"); the regex pass then catches any IPs the sensitive_strings
     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.
     """
     if not value:

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

@@ -23,8 +23,106 @@ from pathlib import Path
 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:
-    """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
     if Path("/.dockerenv").exists():
         return True

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

@@ -1112,10 +1112,18 @@ async def _stream_rtsp(
         "1024000",
         "-max_delay",
         "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",
         "nobuffer",
         "-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 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(

+ 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()
         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)
             await db.flush()
             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 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.schemas.settings import AppSettings as AppSettingsSchema
 
 
 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()
     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
 
     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
         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.services.bambu_cloud_credentials import get_stored_token
 
         bambu: 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
 cross-validated against live MakerWorld traffic. Authenticated calls reuse
 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
 Bambu Lab, and not intended to circumvent any access control.
@@ -19,211 +23,74 @@ from __future__ import annotations
 
 import asyncio
 import logging
-import re
-import ssl
 from collections.abc import Awaitable, Callable
+from dataclasses import replace
 from typing import Any
 from urllib.parse import urlparse
 
-import certifi
 import httpx
 
 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
 
 
-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:
     """Register an app-scoped ``httpx.AsyncClient`` for service reuse.
 
     Same pattern as ``bambu_cloud.set_shared_http_client`` — lets the FastAPI
     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
     _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.
 
     Mirrors ``BambuCloudService``'s construction pattern so callers can
@@ -233,15 +100,26 @@ class MakerWorldService:
 
     def __init__(
         self,
+        *,
         client: httpx.AsyncClient | None = None,
         auth_token: str | None = None,
+        user: Any | 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
         # same Bambu Cloud bearer as everything else, so a rejection here means
         # the credential is dead app-wide — see ``build_authenticated_cloud``.
         self._on_auth_failure = on_auth_failure
         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:
             self._client = client
             self._owns_client = False
@@ -252,6 +130,7 @@ class MakerWorldService:
             self._client = httpx.AsyncClient(timeout=30.0)
             self._owns_client = True
         self._auth_token = auth_token
+        self._user = user
 
     async def close(self) -> None:
         if self._owns_client:
@@ -283,6 +162,121 @@ class MakerWorldService:
             headers["Authorization"] = f"Bearer {self._auth_token}"
         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]:
         """GET ``{MAKERWORLD_API_BASE}{path}`` returning the decoded JSON body.
 
@@ -376,49 +370,6 @@ class MakerWorldService:
             )
         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]:
         """Fetch full model metadata. Works anonymously.
 
@@ -510,8 +461,13 @@ class MakerWorldService:
     async def download_3mf(self, signed_url: str) -> tuple[bytes, str]:
         """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.
 
         Returns ``(file_bytes, suggested_filename)``.
@@ -522,7 +478,7 @@ class MakerWorldService:
             raise MakerWorldUrlError(f"Invalid download URL: {exc}") from exc
 
         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:
             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
         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.
         """
         try:
@@ -581,7 +538,7 @@ class MakerWorldService:
             raise MakerWorldUrlError(f"Invalid thumbnail URL: {exc}") from exc
 
         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}")
 
         # ``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
 
+from backend.app.utils.paho_teardown import retire_paho_client
+
 logger = logging.getLogger(__name__)
 
 
@@ -200,7 +202,7 @@ class MQTTRelayService:
                 self._disconnection_event = threading.Event()
                 self.client.disconnect()
                 await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
-                self.client.loop_stop()
+                retire_paho_client(self.client, "relay")
             except Exception as e:
                 logger.debug("MQTT disconnect error (ignored): %s", e)
             finally:

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

@@ -13,6 +13,8 @@ from typing import Any
 
 import paho.mqtt.client as mqtt
 
+from backend.app.utils.paho_teardown import retire_paho_client
+
 logger = logging.getLogger(__name__)
 
 
@@ -482,7 +484,7 @@ class MQTTSmartPlugService:
                 self._disconnection_event = threading.Event()
                 self.client.disconnect()
                 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:
                 logger.debug("MQTT smart plug disconnect error (ignored): %s", e)
             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)
 
 
-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,
     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
     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
     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:
         import psutil
@@ -49,7 +61,7 @@ def _get_network_interfaces_psutil() -> list[dict]:
         logger.warning("psutil not available, interface detection unavailable on this platform")
         return []
 
-    interfaces = []
+    entries = []
     try:
         addrs_by_iface = psutil.net_if_addrs()
         stats_by_iface = psutil.net_if_stats()
@@ -58,10 +70,14 @@ def _get_network_interfaces_psutil() -> list[dict]:
         return []
 
     for name, addrs in addrs_by_iface.items():
+        if exclude_by_name and _is_excluded(name):
+            continue
+
         stats = stats_by_iface.get(name)
         if stats is not None and not stats.isup:
             continue
 
+        ipv4_count = 0
         for addr in addrs:
             if addr.family != socket.AF_INET:
                 continue
@@ -82,25 +98,53 @@ def _get_network_interfaces_psutil() -> list[dict]:
             except ValueError:
                 continue
 
-            interfaces.append(
+            entries.append(
                 {
                     "name": name,
                     "ip": ip,
                     "netmask": netmask,
                     "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.
 
+    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:
         List of dicts with name, ip, netmask, subnet, broadcast
     """
@@ -121,7 +165,7 @@ def get_network_interfaces() -> list[dict]:
             name = iface[1]
 
             # Skip excluded interfaces
-            if _is_excluded(name):
+            if not include_excluded and _is_excluded(name):
                 continue
 
             try:
@@ -171,18 +215,25 @@ def get_network_interfaces() -> list[dict]:
     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.
-    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:
         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:
         result = subprocess.run(
@@ -193,17 +244,17 @@ def get_all_interface_ips() -> list[dict]:
         )
         if result.returncode != 0:
             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)
     except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as 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 = []
     for iface in interfaces_data:
         ifname = iface.get("ifname", "")
-        if _is_excluded(ifname):
+        if not include_excluded and _is_excluded(ifname):
             continue
 
         ipv4_count = 0
@@ -236,23 +287,71 @@ def get_all_interface_ips() -> list[dict]:
             )
             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 [
         {
             **iface,
             "is_alias": False,
             "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:
     """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
         # explicitly mapped this event to a 1-5 value; otherwise fall through
         # 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 {}
         if event_type and isinstance(event_priorities, dict):
             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:
                 priority = int(raw) if raw is not None else None
             except (TypeError, ValueError):

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

@@ -25,7 +25,10 @@ from __future__ import annotations
 import io
 import logging
 import re
+import threading
 import zipfile
+from collections import defaultdict
+from dataclasses import dataclass, field
 
 logger = logging.getLogger(__name__)
 
@@ -42,11 +45,33 @@ _PLATE_PNG_SMALL_SIZE = 128
 _BAMBU_GREEN = "#00AE42"
 _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``,
 # ``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)
             if not missing:
                 return threemf_bytes
-            if "3D/3dmodel.model" not in names:
+            if _MODEL_ROOT not in names:
                 logger.debug(
                     "plate_thumbnail: sliced 3MF has no 3D/3dmodel.model — skipping (plates %s)",
                     sorted(missing),
@@ -82,7 +107,8 @@ def inject_plate_thumbnails_if_missing(threemf_bytes: bytes) -> bytes:
         return threemf_bytes
 
     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:
         logger.warning(
             "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()
 
-    import matplotlib
-
-    matplotlib.use("Agg")
-    import matplotlib.pyplot as plt
     import trimesh
     from matplotlib.colors import LightSource
     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
-
-    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_max = vertices.max(axis=0)
     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
     # list form is ~30x slower to construct. Paid twice per plate: once per size.
-    faces = mesh.faces
     poly3d = scaled[faces]
 
     # 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_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
 
 
-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
     # in an environment without matplotlib still works. stl_thumbnail's own
     # 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
 
-    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)
     ax = fig.add_subplot(111, projection="3d")
     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.set_axis_off()
     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()
     fig.savefig(
@@ -242,7 +488,6 @@ def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) ->
         pad_inches=0.05,
         dpi=100,
     )
-    plt.close(fig)
     return buf.getvalue()
 
 

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

@@ -29,7 +29,6 @@ import logging
 from fastapi import HTTPException
 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.core.permissions import Permission
 from backend.app.models.local_preset import LocalPreset
@@ -40,6 +39,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudError,
     BambuCloudService,
 )
+from backend.app.services.bambu_cloud_credentials import get_stored_token
 from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
 
 logger = logging.getLogger(__name__)

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

@@ -6,6 +6,7 @@ import logging
 import time
 import uuid
 from collections import deque
+from collections.abc import Mapping
 from dataclasses import dataclass
 from datetime import datetime, timedelta, timezone
 from pathlib import Path
@@ -58,11 +59,13 @@ from backend.app.services.printer_manager import (
     supports_drying_while_printing,
 )
 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.filament_types import canonical_filament_type
 from backend.app.utils.filename import derive_remote_filename
 from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.printer_models import (
+    is_dual_nozzle_model,
     is_gcode_compatible,
     is_nozzle_rack_model,
     normalize_printer_model,
@@ -471,6 +474,77 @@ def _mapping_is_all_unresolved(mapping: list | None) -> bool:
 _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:
     """``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}
 
+            # 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
             # still in its post-dispatch hold window. Empirically, the DB seed
             # above can miss in-flight items in a multi-plate batch — same-file
@@ -1196,7 +1391,7 @@ class PrintScheduler:
             # timing.
             for held_printer_id in list(self._dispatch_holds.keys()):
                 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
             # 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.
             for _task, inflight_pid in self._inflight.values():
                 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).
             #
@@ -1310,11 +1505,18 @@ class PrintScheduler:
                     if sched.tzinfo is None:
                         sched = sched.replace(tzinfo=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
                         continue
 
                 # Skip items that require 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
                     continue
 
@@ -1325,24 +1527,35 @@ class PrintScheduler:
                     # to shut the enclosure" need to read differently, and only
                     # 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)
-                    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:
+                        # 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
                         continue
 
                     # Specific printer assignment (existing behavior)
                     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
 
                     # Check if printer is idle
@@ -1374,16 +1587,36 @@ class PrintScheduler:
                                 printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
                             else:
                                 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
                         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
 
                     # Check if printer is idle (busy with another print)
                     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
 
                     # 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(
                         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
 
                     # Check condition (previous print success)
@@ -1401,6 +1641,8 @@ class PrintScheduler:
                             item.status = "skipped"
                             item.error_message = "Previous print failed or was aborted"
                             item.completed_at = datetime.now(timezone.utc)
+                            # Not pending any more, so not waiting for anything.
+                            item.waiting_reason = None
                             await db.commit()
                             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
                     # acknowledge via the ▶ button (which re-checks live).
                     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
 
                     # Hold this item back for the next pass rather than racing
@@ -1438,7 +1684,12 @@ class PrintScheduler:
                     # its place in this printer's queue.
                     if _library_row_conflict(item):
                         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
 
                     # Print takes priority: stop a cycle Bambuddy armed, now
@@ -1466,9 +1717,14 @@ class PrintScheduler:
                     # Queue the dispatch instead of running it here — see
                     # _dispatch_selected(). busy_printers still gets the printer
                     # 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)
                     dispatch_ids.append(item.id)
-                    busy_printers.add(item.printer_id)
+                    claim_printer(item.printer_id)
 
                     # SJF starvation guard: mark items that were jumped
                     if sjf_enabled and item.print_time_seconds is not None:
@@ -1674,7 +1930,7 @@ class PrintScheduler:
 
                         _claim_library_row(item)
                         dispatch_ids.append(item.id)
-                        busy_printers.add(printer_id)
+                        claim_printer(printer_id)
 
                         # SJF starvation guard: mark model-based items that were jumped
                         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.
             if 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
             # 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)
         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:
         """Read a setting value from the database."""
         result = await db.execute(select(Settings).where(Settings.key == key))
@@ -3736,6 +4018,61 @@ class PrintScheduler:
                 continue
         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
     def resolve_humidity_threshold(trays: list[dict], thresholds: dict[str, int], fallback: int) -> int:
         """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()
             if not tray_type:
                 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:
             return default
         return min(candidates)
@@ -3779,9 +4118,17 @@ class PrintScheduler:
             tray_type = tray.get("tray_type", "")
             if not tray_type:
                 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:
                 continue
 
@@ -3951,21 +4298,12 @@ class PrintScheduler:
 
                 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_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
         existing drying-preset normalisation (split-at-space, upper-case) so
         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:
         """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
         first, so PETG-CF and PA-CF keep the hotter rows they are listed with
         (#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
-        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(
         self,
@@ -5081,6 +5434,34 @@ class PrintScheduler:
                 return False
         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(
         self,
         db: AsyncSession,
@@ -5104,9 +5485,14 @@ class PrintScheduler:
           2. Chamber target — `item.preheat_chamber_target_override` if non-null;
              else max of `preheat_filament_targets[normalize(t.tray_type)]`
              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):
                send M141 to the resolved target, then wait for the chamber sensor
                to reach it (or the max-wait timeout to elapse).
@@ -5142,9 +5528,10 @@ class PrintScheduler:
         # Chamber target resolution:
         #   1. Explicit per-item override beats everything (user knows best).
         #   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)
         if explicit_target is not None and explicit_target > 0:
             chamber_target = int(explicit_target)
@@ -5157,6 +5544,31 @@ class PrintScheduler:
             chamber_target = self._derive_chamber_target(printer, targets, item)
             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
         if bed_target <= 0:
             # No bed temperature in the slicer metadata. When the print needs a
@@ -6632,6 +7044,54 @@ class PrintScheduler:
             if 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.
         # nozzle_mapping rides through verbatim — JSON string captured from
         # Bambu Studio's project_file on VP intake (#1780); the MQTT layer
@@ -6648,7 +7108,7 @@ class PrintScheduler:
             vibration_cali=item.vibration_cali,
             layer_inspect=item.layer_inspect,
             timelapse=effective_timelapse,
-            use_ams=item.use_ams,
+            use_ams=effective_use_ams,
             nozzle_offset_cali=item.nozzle_offset_cali,
             nozzle_mapping=item.nozzle_mapping
             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**
 -- 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 dispatch says where it went: the ``project_file`` command carries ``url``,
 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).
 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
 # `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

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

@@ -2,7 +2,7 @@
 
 Runs the checks a maintainer performs by hand when triaging a
 "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.
 
 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 ipaddress
 import logging
+import os
 import socket
 import ssl
+import subprocess
+import sys
+from pathlib import Path
 
 from backend.app.models.printer import Printer
 from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
 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.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.network_utils import find_local_ipv4_network
 from backend.app.services.print_storage import (
     REASON_INTERNAL_STORAGE,
     StorageVerdict,
@@ -203,48 +208,202 @@ def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
     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:
         for _idx, name in socket.if_nameindex():
-            if name.startswith(("docker", "br-", "veth", "virbr")):
+            if name.startswith(_HOST_INFRA_PREFIXES):
                 return "host"
     except Exception:
         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:
         s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
         try:
             # 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]
         finally:
             s.close()
     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
 
 
-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:
-        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:
         return None
-    if addr_a.version != 4 or addr_b.version != 4:
+    if printer_addr.version != 4 or host_addr.version != 4:
         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(
@@ -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
-    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(
-            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:
-        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 ---
     # 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":
         checks.append(DiagnosticCheck(id="subnet", status="skip"))
     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:
             checks.append(DiagnosticCheck(id="subnet", status="skip"))
         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
         # 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
         # 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,
     get_stage_name,
 )
+from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.kprofile_lookup import build_slot_k_resolver
 
 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
 
 
-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)
 # 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").
@@ -1417,22 +1400,10 @@ def printer_state_to_dict(
                         "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
             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
 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
 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,
     SlicerApiService,
 )
+from backend.app.utils.threemf_tools import sanitize_project_settings_sentinels
 
 logger = logging.getLogger(__name__)
 
@@ -203,13 +207,17 @@ async def get_preview_filaments(
 
     Uses the file's embedded settings (``slice_without_profiles``) since the
     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
     back to whatever heuristic it has (typically the project_filaments +
     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)
     key: _PreviewCacheKey = (kind, source_id, plate_id, h)
     cached = _preview_cache.get(key)
@@ -231,6 +239,17 @@ async def get_preview_filaments(
         # while the slicer is visibly working.
         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 with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
                 return await svc.slice_without_profiles(
@@ -242,7 +261,7 @@ async def get_preview_filaments(
                 )
 
         try:
-            result = await _slice(file_bytes)
+            result = await _slice(slice_bytes)
         except SlicerApiError as e:
             # One retry, and only for a custom-G-code template the sidecar
             # cannot parse — a file from a Studio newer than the sidecar. The
@@ -259,7 +278,7 @@ async def get_preview_filaments(
             retry_bytes = None
             option = _unparsable_gcode_option(str(e))
             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:
                 logger.warning(
                     "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 logging
+import re
 
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
@@ -49,6 +51,65 @@ from backend.app.utils.filament_types import is_material_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:
     """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
     # filament_id; without it the raw cloud id ends up in tray_info_idx
     # 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
         try:
             from backend.app.api.routes.cloud import build_authenticated_cloud
@@ -147,8 +227,30 @@ async def resolve_slicer_filament(
                     type_override = _preset_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", "")
                         if cloud_name:
                             sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
@@ -245,10 +347,16 @@ async def resolve_slicer_filament(
     #      the original assign.
     #   3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
     #      "(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
     # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
     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 = ""
         # 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.
 
-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
 
 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
 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:
     """Get the local IP address."""
@@ -43,8 +49,10 @@ def _get_local_ip() -> str:
 class CertificateService:
     """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
     """
 
@@ -90,9 +98,10 @@ class CertificateService:
         is broken even though both files exist on disk. ``ensure_certificates``
         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:
             if not self.ca_cert_path.exists():
@@ -213,10 +222,25 @@ class CertificateService:
             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(
             [
-                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,
             )
+            .add_extension(ca_skid, critical=False)
             .sign(ca_key, hashes.SHA256())
         )
 
@@ -313,12 +338,23 @@ class CertificateService:
         # Issuer is the CA
         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)
         local_ip = _get_local_ip()
         logger.info("Generating printer certificate with CN=%s, local IP: %s", self.serial, local_ip)
 
         # Build printer certificate signed by CA
-        printer_cert = (
+        printer_cert_builder = (
             x509.CertificateBuilder()
             .subject_name(printer_subject)
             .issuer_name(issuer)
@@ -357,9 +393,19 @@ class CertificateService:
                 ),
                 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
         self.key_path.write_bytes(
             printer_key.private_bytes(
@@ -380,7 +426,7 @@ class CertificateService:
         self.cert_path.write_bytes(cert_chain)
 
         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)
         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.
 
-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
 ``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
 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
@@ -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
     # claim this index on this extruder.
     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 []:
         if kp.slot_id is None or not kp.k_value:
             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)
         except (ValueError, TypeError):
             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}
 
+    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:
         if cali_idx is None:
             return None
         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
         # 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
-        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

+ 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))
         break
     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["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.integration
     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.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.integration
     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}
 
+    @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:
     """#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
     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)."""
-        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)
         assert token is None
@@ -275,7 +275,8 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     async def test_store_and_get_global_token(self, db_session):
         """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)
         token, email, region = await get_stored_token(db_session, user=None)
@@ -286,9 +287,10 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     async def test_store_and_get_per_user_token(self, db_session):
         """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.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")
         db_session.add(user)
@@ -309,9 +311,10 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     async def test_per_user_token_does_not_affect_global(self, db_session):
         """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.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")
         db_session.add(user)
@@ -352,7 +355,8 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     async def test_clear_global_token(self, db_session):
         """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 clear_token(db_session, user=None)
@@ -365,9 +369,10 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     async def test_two_users_independent_tokens(self, db_session):
         """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.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_b = User(username="user_b", password_hash=get_password_hash("pass"), role="user")
@@ -406,9 +411,10 @@ class TestCloudRegionPersistence:
     @pytest.mark.asyncio
     async def test_region_survives_roundtrip_per_user(self, db_session):
         """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.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")
         db_session.add(user)
@@ -429,7 +435,8 @@ class TestCloudRegionPersistence:
     @pytest.mark.asyncio
     async def test_region_survives_roundtrip_global_fallback(self, db_session):
         """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)
         _token, _email, region = await get_stored_token(db_session, user=None)
@@ -438,7 +445,8 @@ class TestCloudRegionPersistence:
     @pytest.mark.asyncio
     async def test_invalid_region_is_normalised_to_global(self, db_session):
         """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)
         _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 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_REGION_KEY,
     CLOUD_TOKEN_KEY,
     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:

+ 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))
         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
 
 
+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:
     """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
 
 
+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:
     """`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
@@ -1578,14 +1905,14 @@ class TestSpoolmanSlotAssignmentDuringRunout:
             db_session.add(Settings(key=key, value=value))
         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 backend.app.main import on_ams_change
 
         # A tray the firmware has cleared: parse_ams_tray returns None, which
         # 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.health_check = AsyncMock(return_value=True)
@@ -1628,6 +1955,37 @@ class TestSpoolmanSlotAssignmentDuringRunout:
         db_session.expunge_all()
         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.integration
     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
 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:
@@ -679,19 +689,43 @@ class TestLibraryAddToQueueAPI:
 
         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.integration
     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()
 
         data = {"file_ids": [9999]}
         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.integration
@@ -708,11 +742,172 @@ class TestLibraryAddToQueueAPI:
 
         data = {"file_ids": [lib_file.id]}
         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
         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:

+ 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.library import LibraryFile
 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:
@@ -104,6 +110,20 @@ def _fake_service(**stubs):
     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:
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -161,9 +181,13 @@ class TestResolveEndpoint:
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         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)
 
         with patch("backend.app.api.routes.makerworld._build_service", build):
@@ -173,14 +197,22 @@ class TestResolveEndpoint:
                 headers={"X-API-Key": key},
             )
         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
-        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:
@@ -195,23 +227,12 @@ class TestImportEndpoint:
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         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(
-            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)):
@@ -249,16 +270,9 @@ class TestImportEndpoint:
         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)
 
-        design = {
-            "id": 1400373,
-            "modelId": "US2bb73b106683e5",
-            "instances": [{"profileId": 298919107}],
-        }
-        manifest = {"name": "cube.3mf", "url": "https://makerworld.bblmw.com/x.3mf"}
         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:
@@ -270,11 +284,19 @@ class TestImportEndpoint:
         assert resp.status_code == 200, resp.text
         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.
         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.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()
         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.integration
     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.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")
 
         mock_client = MagicMock()
@@ -1738,12 +1749,20 @@ class TestConfigureAMSSlotAPI:
 
             assert response.status_code == 200
             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.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")
 
         mock_client = MagicMock()
@@ -1789,13 +1808,19 @@ class TestConfigureAMSSlotAPI:
 
             assert response.status_code == 200
             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.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")
 
         mock_client = MagicMock()
@@ -1834,8 +1859,8 @@ class TestConfigureAMSSlotAPI:
 
             assert response.status_code == 200
             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.integration
@@ -1873,13 +1898,19 @@ class TestConfigureAMSSlotAPI:
 
     @pytest.mark.asyncio
     @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")
 
@@ -1910,10 +1941,47 @@ class TestConfigureAMSSlotAPI:
 
             assert response.status_code == 200
             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"
-            # 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:

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

@@ -220,13 +220,18 @@ class TestProjectUrlAndCoverImage:
         assert response.status_code == 400
 
     @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 backend.app.api.routes.projects import router
@@ -242,21 +247,20 @@ class TestProjectUrlAndCoverImage:
 
         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.
         # 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
         # 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]
         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
         total = (await db_session.execute(select(PrintQueueVariant))).scalars().all()
         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.monthly_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)
         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:
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -1980,7 +2001,7 @@ class TestBackupKeyFiles:
         # Build a minimal ZIP with a stub DB and the key file.
         buf = io.BytesIO()
         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")
         buf.seek(0)
 
@@ -2020,7 +2041,7 @@ class TestBackupKeyFiles:
 
         buf = io.BytesIO()
         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.
         buf.seek(0)
 
@@ -2061,7 +2082,7 @@ class TestBackupKeyFiles:
         # Build ZIP with a key file that we will fail to write to DATA_DIR.
         buf = io.BytesIO()
         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")
         buf.seek(0)
 
@@ -2141,7 +2162,7 @@ class TestBackupKeyFiles:
         assert new_key != old_key
         buf = io.BytesIO()
         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)
         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"]
         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:
     """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)

Неке датотеке нису приказане због велике количине промена