Bladeren bron

Merge branch '0.2.1b2' into feature/cost_tracker

Keybored 3 maanden geleden
bovenliggende
commit
85645ce184
38 gewijzigde bestanden met toevoegingen van 2921 en 463 verwijderingen
  1. 14 0
      .pre-commit-config.yaml
  2. 13 1
      CHANGELOG.md
  3. 1 0
      README.md
  4. 1 0
      SECURITY.md
  5. 30 119
      backend/app/api/routes/archives.py
  6. 32 0
      backend/app/api/routes/background_dispatch.py
  7. 26 131
      backend/app/api/routes/library.py
  8. 23 12
      backend/app/api/routes/print_queue.py
  9. 10 0
      backend/app/api/routes/websocket.py
  10. 125 107
      backend/app/main.py
  11. 11 5
      backend/app/schemas/archive.py
  12. 1 0
      backend/app/schemas/library.py
  13. 856 0
      backend/app/services/background_dispatch.py
  14. 56 9
      backend/app/services/bambu_ftp.py
  15. 6 3
      backend/app/services/bambu_mqtt.py
  16. 2 2
      backend/app/services/print_scheduler.py
  17. 53 25
      backend/app/services/usage_tracker.py
  18. 243 0
      backend/tests/integration/test_background_dispatch_api.py
  19. 322 0
      backend/tests/unit/services/test_background_dispatch.py
  20. 44 0
      backend/tests/unit/services/test_bambu_ftp.py
  21. 271 0
      backend/tests/unit/services/test_usage_tracker.py
  22. 88 0
      frontend/src/__tests__/components/PrintModalDispatchToast.test.tsx
  23. 23 2
      frontend/src/api/client.ts
  24. 30 18
      frontend/src/components/PrintModal/index.tsx
  25. 440 11
      frontend/src/contexts/ToastContext.tsx
  26. 9 1
      frontend/src/hooks/useWebSocket.ts
  27. 27 0
      frontend/src/i18n/locales/de.ts
  28. 27 0
      frontend/src/i18n/locales/en.ts
  29. 27 0
      frontend/src/i18n/locales/fr.ts
  30. 27 0
      frontend/src/i18n/locales/it.ts
  31. 26 0
      frontend/src/i18n/locales/ja.ts
  32. 27 0
      frontend/src/i18n/locales/pt-BR.ts
  33. 28 15
      frontend/src/pages/InventoryPage.tsx
  34. 0 0
      static/assets/index-DJax8qcY.css
  35. 0 0
      static/assets/index-nSSxwAwH.js
  36. 0 0
      static/assets/index-pLsuHUG_.css
  37. 0 0
      static/assets/index-uDg4paLx.js
  38. 2 2
      static/index.html

+ 14 - 0
.pre-commit-config.yaml

@@ -42,3 +42,17 @@ repos:
         pass_filenames: false
         pass_filenames: false
         types: [python]
         types: [python]
         files: ^backend/app/
         files: ^backend/app/
+      - id: frontend-typecheck
+        name: TypeScript type check
+        entry: bash -c 'cd frontend && npx tsc --noEmit'
+        language: system
+        pass_filenames: false
+        files: ^frontend/src/
+        types_or: [ts, tsx]
+      - id: frontend-lint
+        name: ESLint
+        entry: bash -c 'cd frontend && npx eslint .'
+        language: system
+        pass_filenames: false
+        files: ^frontend/src/
+        types_or: [ts, tsx]

+ 13 - 1
CHANGELOG.md

@@ -5,11 +5,23 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.1b2] - Unreleased
 ## [0.2.1b2] - Unreleased
 
 
 ### Fixed
 ### Fixed
-- **Queue Stuck on "Busy" for "Any Model" Jobs** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — When a print was queued with "Any [Model]" (e.g., "Any P1S"), it was created with `printer_id=NULL` and `target_model="P1S"`. After the assigned printer finished, the queue widget queried only for items matching `printer_id=X`, missing the next pending model-based item (`printer_id IS NULL`). With no next item found, the "Clear Plate & Start Next" button never appeared, leaving the scheduler stuck reporting "Busy". The queue API now accepts an optional `target_model` parameter; when combined with `printer_id`, it uses OR logic to also return unassigned items whose `target_model` matches the printer's model. The frontend passes the printer's model through to this query.
+- **Spool Usage Lost When Spool Runs Empty Mid-Print** ([#459](https://github.com/maziggy/bambuddy/issues/459)) — When a spool ran empty during a print and the AMS auto-switched to a backup spool, the `on_ams_change` handler eagerly deleted the empty spool's `SpoolAssignment` record (fingerprint mismatch). When `on_print_complete` later ran, it queried `SpoolAssignment` live from the database, found nothing, and silently dropped usage. Now snapshots all spool assignments at print start into the `PrintSession`, so usage is correctly attributed at completion regardless of mid-print AMS changes.
+- **K-Profile Response Race Condition Crash** ([#462](https://github.com/maziggy/bambuddy/issues/462)) — An unsolicited or late K-profile MQTT response could crash the MQTT handler with `AttributeError: 'NoneType' object has no attribute 'set'`. The MQTT callback thread checked `self._pending_kprofile_response` (not None) at line 2698, but between that check and the `.set()` call, the asyncio thread's `finally` block in `get_kprofiles()` could clear the attribute to `None` after a timeout — a classic TOCTOU race. Fixed by capturing the event reference in a local variable before the check.
+- **Queue Stuck on "Busy" for "Any Model" Jobs** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — When a print was queued with "Any [Model]" (e.g., "Any P1S"), it was created with `printer_id=NULL` and `target_model="P1S"`. After the assigned printer finished, the queue widget queried only for items matching `printer_id=X`, missing the next pending model-based item (`printer_id IS NULL`). With no next item found, the "Clear Plate & Start Next" button never appeared, leaving the scheduler stuck reporting "Busy". The queue API now accepts an optional `target_model` parameter; when combined with `printer_id`, it uses OR logic to also return unassigned items whose `target_model` matches the printer's model. The frontend passes the printer's model through to this query. Additionally, the backend now resolves the printer's model server-side from the database when the frontend doesn't provide `target_model` (e.g., when the printer was added without selecting a model), ensuring the OR logic works regardless of whether the client knows the printer's model.
+- **Queue "Any Model" Jobs Stuck in "Waiting" After Plate Clear** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — After the queue visibility fix above, "Any Model" jobs were correctly assigned to an idle printer but immediately crashed with `'>=' not supported between instances of 'str' and 'int'` when computing AMS filament mapping. MQTT raw data returns AMS unit and tray IDs as strings, but `_build_loaded_filaments()` compared them to integers without casting. The crash prevented the assignment from committing, so the scheduler retried every 30 seconds in an infinite loop. Cast `ams_id` and `tray_id` to `int()` to match the pattern already used for external spool IDs.
+- **SD Card Cleanup After Print Never Runs** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The post-print SD card cleanup (which deletes uploaded gcode from the printer root to prevent phantom prints on power cycle) used `printer_manager.get_printer()`, which returns a `PrinterInfo` with only `name` and `serial_number`. Accessing `.ip_address`, `.access_code`, and `.model` raised `AttributeError`, silently caught by the outer exception handler. Replaced with a DB query for the `Printer` model, matching the pattern used everywhere else in `on_print_complete()`.
+- **Inventory Date Format Ignores Settings** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory page used a local `formatDate()` that hardcoded the `en-GB` locale, always displaying dates in a fixed format regardless of the date format setting. Now fetches the `date_format` setting and uses the shared `formatDateInput()` utility which formats as MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, or browser locale based on the user's choice.
+- **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.
 
 
 ### New Features
 ### New Features
+- **Background Print Dispatch** ([#408](https://github.com/maziggy/bambuddy/pull/408), [#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
 - **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
 - **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
 
 
+### Improved
+- **Spool Assignment Snapshot Test Coverage** — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.
+- **Background Dispatch Test Coverage** — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.
+- **Frontend Pre-Commit Hooks** ([#458](https://github.com/maziggy/bambuddy/issues/458)) — Added `frontend-typecheck` (`tsc --noEmit`) and `frontend-lint` (`eslint .`) hooks to the pre-commit config. Both hooks only trigger when `frontend/src/**/*.{ts,tsx}` files are staged.
+
 ## [0.2.1b] - 2026-02-19
 ## [0.2.1b] - 2026-02-19
 
 
 ### Fixed
 ### Fixed

+ 1 - 0
README.md

@@ -98,6 +98,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - CSV/Excel export
 - CSV/Excel export
 
 
 ### ⏰ Scheduling & Automation
 ### ⏰ Scheduling & Automation
+- **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
 - Print queue with drag-and-drop
 - Print queue with drag-and-drop
 - Multi-printer selection (send to multiple printers at once)
 - Multi-printer selection (send to multiple printers at once)
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering

+ 1 - 0
SECURITY.md

@@ -40,6 +40,7 @@ Please include the following information in your report:
 | Version | Supported          |
 | Version | Supported          |
 | ------- | ------------------ |
 | ------- | ------------------ |
 | 0.1.x   | :white_check_mark: |
 | 0.1.x   | :white_check_mark: |
+| 0.2.x   | :white_check_mark: |
 
 
 ## Security Considerations
 ## Security Considerations
 
 

+ 30 - 119
backend/app/api/routes/archives.py

@@ -2431,7 +2431,8 @@ async def get_archive_plates(
                 for gf in gcode_files:
                 for gf in gcode_files:
                     # "Metadata/plate_5.gcode" -> 5
                     # "Metadata/plate_5.gcode" -> 5
                     try:
                     try:
-                        plate_str = gf[15:-6]  # Remove "Metadata/plate_" and ".gcode"
+                        # Remove "Metadata/plate_" and ".gcode"
+                        plate_str = gf[15:-6]
                         plate_indices.append(int(plate_str))
                         plate_indices.append(int(plate_str))
                     except ValueError:
                     except ValueError:
                         pass  # Skip gcode file with non-numeric plate index
                         pass  # Skip gcode file with non-numeric plate index
@@ -2834,14 +2835,9 @@ async def reprint_archive(
         )
         )
     ),
     ),
 ):
 ):
-    """Send an archived 3MF file to a printer and start printing."""
-    from backend.app.main import register_expected_print
+    """Dispatch an archived 3MF file for send/start on a printer."""
     from backend.app.models.printer import Printer
     from backend.app.models.printer import Printer
-    from backend.app.services.bambu_ftp import (
-        get_ftp_retry_settings,
-        upload_file_async,
-        with_ftp_retry,
-    )
+    from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
@@ -2871,139 +2867,54 @@ async def reprint_archive(
     if not printer_manager.is_connected(printer_id):
     if not printer_manager.is_connected(printer_id):
         raise HTTPException(400, "Printer is not connected")
         raise HTTPException(400, "Printer is not connected")
 
 
-    # Get the sliced 3MF file path
     if not archive.file_path:
     if not archive.file_path:
         raise HTTPException(
         raise HTTPException(
             404,
             404,
             "No 3MF file available for this archive. "
             "No 3MF file available for this archive. "
             "The file could not be downloaded from the printer when the print was recorded.",
             "The file could not be downloaded from the printer when the print was recorded.",
         )
         )
+
+    # Validate archive file exists
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
         raise HTTPException(404, "Archive file not found")
         raise HTTPException(404, "Archive file not found")
 
 
-    # Upload file to printer via FTP
-    from backend.app.services.bambu_ftp import delete_file_async
-
-    # Use a clean filename to avoid issues with double extensions like .gcode.3mf
-    # The printer might reject filenames with unusual extensions
-    base_name = archive.filename
-    if base_name.endswith(".gcode.3mf"):
-        base_name = base_name[:-10]  # Remove .gcode.3mf
-    elif base_name.endswith(".3mf"):
-        base_name = base_name[:-4]  # Remove .3mf
-    remote_filename = f"{base_name}.3mf"
-    remote_path = f"/{remote_filename}"
+    plate_name = body.plate_name
+    if not plate_name and body.plate_id is not None:
+        plate_name = f"Plate {body.plate_id}"
 
 
-    # Get FTP retry settings
-    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
-
-    logger.info(
-        f"Reprint FTP upload starting: printer={printer.name} ({printer.model}), "
-        f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, "
-        f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}"
-    )
-
-    # Delete existing file if present (avoids 553 error)
-    logger.debug("Deleting existing file %s if present...", remote_path)
-    delete_result = await delete_file_async(
-        printer.ip_address,
-        printer.access_code,
-        remote_path,
-        socket_timeout=ftp_timeout,
-        printer_model=printer.model,
-    )
-    logger.debug("Delete result: %s", delete_result)
-
-    if ftp_retry_enabled:
-        uploaded = await with_ftp_retry(
-            upload_file_async,
-            printer.ip_address,
-            printer.access_code,
-            file_path,
-            remote_path,
-            socket_timeout=ftp_timeout,
-            printer_model=printer.model,
-            max_retries=ftp_retry_count,
-            retry_delay=ftp_retry_delay,
-            operation_name=f"Upload for reprint to {printer.name}",
-        )
-    else:
-        uploaded = await upload_file_async(
-            printer.ip_address,
-            printer.access_code,
-            file_path,
-            remote_path,
-            socket_timeout=ftp_timeout,
-            printer_model=printer.model,
-        )
+    dispatch_source_name = archive.filename
+    if plate_name:
+        dispatch_source_name = f"{archive.filename} • {plate_name}"
 
 
-    if not uploaded:
-        logger.error(
-            f"FTP upload failed for reprint: printer={printer.name}, model={printer.model}, "
-            f"ip={printer.ip_address}, file={remote_filename}. "
-            "Check logs above for storage diagnostics and specific error codes."
-        )
-        raise HTTPException(
-            500,
-            "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
-            "See server logs for detailed diagnostics.",
+    try:
+        dispatch_result = await background_dispatch.dispatch_reprint_archive(
+            archive_id=archive_id,
+            archive_name=dispatch_source_name,
+            printer_id=printer_id,
+            printer_name=printer.name,
+            options=body.model_dump(exclude_none=True),
+            requested_by_user_id=user.id if user else None,
+            requested_by_username=user.username if user else None,
         )
         )
-
-    # Register this as an expected print so we don't create a duplicate archive
-    register_expected_print(printer_id, remote_filename, archive_id, ams_mapping=body.ams_mapping)
-
-    # Use plate_id from request if provided, otherwise auto-detect from 3MF file
-    if body.plate_id is not None:
-        plate_id = body.plate_id
-    else:
-        # Auto-detect plate ID from 3MF file (legacy behavior for single-plate files)
-        plate_id = 1
-        try:
-            with zipfile.ZipFile(file_path, "r") as zf:
-                for name in zf.namelist():
-                    if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
-                        # Extract plate number from "Metadata/plate_X.gcode"
-                        plate_str = name[15:-6]  # Remove "Metadata/plate_" and ".gcode"
-                        plate_id = int(plate_str)
-                        break
-        except (ValueError, zipfile.BadZipFile, OSError):
-            pass  # Default to plate 1 if detection fails
+    except DispatchEnqueueRejected as e:
+        raise HTTPException(status_code=409, detail=str(e)) from e
 
 
     logger.info(
     logger.info(
-        f"Reprint archive {archive_id}: plate_id={plate_id}, "
-        f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}, "
-        f"flow_cali={body.flow_cali}, vibration_cali={body.vibration_cali}, "
-        f"layer_inspect={body.layer_inspect}, timelapse={body.timelapse}"
-    )
-
-    # Start the print with options
-    started = printer_manager.start_print(
+        "Dispatched reprint archive %s for printer %s (dispatch_job_id=%s, dispatch_position=%s)",
+        archive_id,
         printer_id,
         printer_id,
-        remote_filename,
-        plate_id,
-        ams_mapping=body.ams_mapping,
-        timelapse=body.timelapse,
-        bed_levelling=body.bed_levelling,
-        flow_cali=body.flow_cali,
-        vibration_cali=body.vibration_cali,
-        layer_inspect=body.layer_inspect,
-        use_ams=body.use_ams,
+        dispatch_result["dispatch_job_id"],
+        dispatch_result["dispatch_position"],
     )
     )
 
 
-    if not started:
-        raise HTTPException(500, "Failed to start print")
-
-    # Track who started this print (Issue #206)
-    if user:
-        printer_manager.set_current_print_user(printer_id, user.id, user.username)
-        logger.info("Reprint started by user: %s", user.username)
-
     return {
     return {
-        "status": "printing",
+        "status": "dispatched",
         "printer_id": printer_id,
         "printer_id": printer_id,
         "archive_id": archive_id,
         "archive_id": archive_id,
         "filename": archive.filename,
         "filename": archive.filename,
+        "dispatch_job_id": dispatch_result["dispatch_job_id"],
+        "dispatch_position": dispatch_result["dispatch_position"],
     }
     }
 
 
 
 

+ 32 - 0
backend/app/api/routes/background_dispatch.py

@@ -0,0 +1,32 @@
+from fastapi import APIRouter, HTTPException
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.services.background_dispatch import background_dispatch
+
+router = APIRouter(prefix="/background-dispatch", tags=["background-dispatch"])
+
+
+@router.delete("/{job_id}")
+async def cancel_dispatch_job(
+    job_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+):
+    """Cancel a background-dispatch job.
+
+    Queued jobs are cancelled immediately. Active jobs are marked for
+    cooperative cancellation and will stop at the next cancellation checkpoint.
+    """
+    result = await background_dispatch.cancel_job(job_id)
+
+    if not result["cancelled"]:
+        raise HTTPException(status_code=404, detail="Dispatch job not found")
+
+    return {
+        "status": "cancelling" if result.get("pending") else "cancelled",
+        "job_id": result["job_id"],
+        "source_name": result["source_name"],
+        "printer_id": result["printer_id"],
+        "printer_name": result["printer_name"],
+    }

+ 26 - 131
backend/app/api/routes/library.py

@@ -54,7 +54,7 @@ from backend.app.schemas.library import (
     ZipExtractResponse,
     ZipExtractResponse,
     ZipExtractResult,
     ZipExtractResult,
 )
 )
-from backend.app.services.archive import ArchiveService, ThreeMFParser
+from backend.app.services.archive import ThreeMFParser
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 
@@ -1737,23 +1737,15 @@ async def print_library_file(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
 ):
 ):
-    """Print a library file directly.
+    """Dispatch a library file for send/start on a printer.
 
 
-    This endpoint:
-    1. Creates an archive from the library file
-    2. Uploads the file to the printer
-    3. Starts the print
+    The actual send/start work is handled asynchronously by background
+    dispatch so the UI can continue immediately.
 
 
     Only sliced files (.gcode or .gcode.3mf) can be printed.
     Only sliced files (.gcode or .gcode.3mf) can be printed.
     """
     """
-    from backend.app.main import register_expected_print
     from backend.app.models.printer import Printer
     from backend.app.models.printer import Printer
-    from backend.app.services.bambu_ftp import (
-        delete_file_async,
-        get_ftp_retry_settings,
-        upload_file_async,
-        with_ftp_retry,
-    )
+    from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
     # Use defaults if no body provided
     # Use defaults if no body provided
@@ -1790,131 +1782,34 @@ async def print_library_file(
     if not printer_manager.is_connected(printer_id):
     if not printer_manager.is_connected(printer_id):
         raise HTTPException(status_code=400, detail="Printer is not connected")
         raise HTTPException(status_code=400, detail="Printer is not connected")
 
 
-    # Create archive from the library file
-    archive_service = ArchiveService(db)
-    archive = await archive_service.archive_print(
-        printer_id=printer_id,
-        source_file=file_path,
-        original_filename=lib_file.filename,
-    )
-
-    if not archive:
-        raise HTTPException(status_code=500, detail="Failed to create archive")
-
-    await db.flush()
-
-    # Prepare remote filename
-    base_name = lib_file.filename
-    if base_name.endswith(".gcode.3mf"):
-        base_name = base_name[:-10]
-    elif base_name.endswith(".3mf"):
-        base_name = base_name[:-4]
-    remote_filename = f"{base_name}.3mf"
-    remote_path = f"/{remote_filename}"
+    plate_name = body.plate_name
+    if not plate_name and body.plate_id is not None:
+        plate_name = f"Plate {body.plate_id}"
 
 
-    # Get FTP retry settings
-    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+    dispatch_source_name = lib_file.filename
+    if plate_name:
+        dispatch_source_name = f"{lib_file.filename} • {plate_name}"
 
 
-    logger.info(
-        f"Library print FTP upload starting: printer={printer.name} ({printer.model}), "
-        f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, "
-        f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}"
-    )
-
-    # Delete existing file if present (avoids 553 error)
-    logger.debug("Deleting existing file %s if present...", remote_path)
-    delete_result = await delete_file_async(
-        printer.ip_address,
-        printer.access_code,
-        remote_path,
-        socket_timeout=ftp_timeout,
-        printer_model=printer.model,
-    )
-    logger.debug("Delete result: %s", delete_result)
-
-    # Upload file to printer
-    if ftp_retry_enabled:
-        uploaded = await with_ftp_retry(
-            upload_file_async,
-            printer.ip_address,
-            printer.access_code,
-            file_path,
-            remote_path,
-            socket_timeout=ftp_timeout,
-            printer_model=printer.model,
-            max_retries=ftp_retry_count,
-            retry_delay=ftp_retry_delay,
-            operation_name=f"Upload for print to {printer.name}",
-        )
-    else:
-        uploaded = await upload_file_async(
-            printer.ip_address,
-            printer.access_code,
-            file_path,
-            remote_path,
-            socket_timeout=ftp_timeout,
-            printer_model=printer.model,
-        )
-
-    if not uploaded:
-        logger.error(
-            f"FTP upload failed for library print: printer={printer.name}, model={printer.model}, "
-            f"ip={printer.ip_address}, file={remote_filename}. "
-            "Check logs above for storage diagnostics and specific error codes."
-        )
-        raise HTTPException(
-            status_code=500,
-            detail="Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
-            "See server logs for detailed diagnostics.",
+    try:
+        dispatch_result = await background_dispatch.dispatch_print_library_file(
+            file_id=file_id,
+            filename=dispatch_source_name,
+            printer_id=printer_id,
+            printer_name=printer.name,
+            options=body.model_dump(exclude_none=True),
+            requested_by_user_id=None,
+            requested_by_username=None,
         )
         )
-
-    # Register this as an expected print so we don't create a duplicate archive
-    register_expected_print(printer_id, remote_filename, archive.id, ams_mapping=body.ams_mapping)
-
-    # Determine plate ID
-    if body.plate_id is not None:
-        plate_id = body.plate_id
-    else:
-        plate_id = 1
-        try:
-            with zipfile.ZipFile(file_path, "r") as zf:
-                for name in zf.namelist():
-                    if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
-                        plate_str = name[15:-6]
-                        plate_id = int(plate_str)
-                        break
-        except (ValueError, zipfile.BadZipFile, OSError):
-            pass  # Default plate_id=1 if archive is unreadable or has no gcode
-
-    logger.info(
-        f"Print library file {file_id}: archive_id={archive.id}, plate_id={plate_id}, "
-        f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}"
-    )
-
-    # Start the print
-    started = printer_manager.start_print(
-        printer_id,
-        remote_filename,
-        plate_id,
-        ams_mapping=body.ams_mapping,
-        timelapse=body.timelapse,
-        bed_levelling=body.bed_levelling,
-        flow_cali=body.flow_cali,
-        vibration_cali=body.vibration_cali,
-        layer_inspect=body.layer_inspect,
-        use_ams=body.use_ams,
-    )
-
-    if not started:
-        raise HTTPException(status_code=500, detail="Failed to start print")
-
-    await db.commit()
+    except DispatchEnqueueRejected as e:
+        raise HTTPException(status_code=409, detail=str(e)) from e
 
 
     return {
     return {
-        "status": "printing",
+        "status": "dispatched",
         "printer_id": printer_id,
         "printer_id": printer_id,
-        "archive_id": archive.id,
+        "archive_id": None,
         "filename": lib_file.filename,
         "filename": lib_file.filename,
+        "dispatch_job_id": dispatch_result["dispatch_job_id"],
+        "dispatch_position": dispatch_result["dispatch_position"],
     }
     }
 
 
 
 

+ 23 - 12
backend/app/api/routes/print_queue.py

@@ -270,19 +270,30 @@ async def list_queue(
         if printer_id == -1:
         if printer_id == -1:
             # Special value: filter for unassigned items
             # Special value: filter for unassigned items
             query = query.where(PrintQueueItem.printer_id.is_(None))
             query = query.where(PrintQueueItem.printer_id.is_(None))
-        elif target_model:
-            # Include both printer-specific items AND model-based (unassigned) items
-            query = query.where(
-                or_(
-                    PrintQueueItem.printer_id == printer_id,
-                    and_(
-                        PrintQueueItem.printer_id.is_(None),
-                        func.lower(PrintQueueItem.target_model) == target_model.lower(),
-                    ),
-                )
-            )
         else:
         else:
-            query = query.where(PrintQueueItem.printer_id == printer_id)
+            # Resolve effective model: prefer explicit param, fall back to printer's DB model.
+            # This ensures model-based "Any X" items are returned even when the frontend
+            # doesn't send target_model (e.g. printer.model is NULL on the client side).
+            effective_model = target_model
+            if not effective_model:
+                printer_row = (
+                    await db.execute(select(Printer.model).where(Printer.id == printer_id))
+                ).scalar_one_or_none()
+                effective_model = printer_row
+
+            if effective_model:
+                # Include both printer-specific items AND model-based (unassigned) items
+                query = query.where(
+                    or_(
+                        PrintQueueItem.printer_id == printer_id,
+                        and_(
+                            PrintQueueItem.printer_id.is_(None),
+                            func.lower(PrintQueueItem.target_model) == effective_model.lower(),
+                        ),
+                    )
+                )
+            else:
+                query = query.where(PrintQueueItem.printer_id == printer_id)
     elif target_model:
     elif target_model:
         query = query.where(func.lower(PrintQueueItem.target_model) == target_model.lower())
         query = query.where(func.lower(PrintQueueItem.target_model) == target_model.lower())
     if status:
     if status:

+ 10 - 0
backend/app/api/routes/websocket.py

@@ -3,6 +3,7 @@ import logging
 from fastapi import APIRouter, WebSocket, WebSocketDisconnect
 from fastapi import APIRouter, WebSocket, WebSocketDisconnect
 
 
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
+from backend.app.services.background_dispatch import background_dispatch
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -27,6 +28,15 @@ async def websocket_endpoint(websocket: WebSocket):
                     "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
                     "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
                 }
                 }
             )
             )
+
+        dispatch_state = await background_dispatch.get_state()
+        if (dispatch_state.get("dispatched", 0) + dispatch_state.get("processing", 0)) > 0:
+            await websocket.send_json(
+                {
+                    "type": "background_dispatch",
+                    "data": dispatch_state,
+                }
+            )
         logger.info("Sent initial status for %s printers", len(statuses))
         logger.info("Sent initial status for %s printers", len(statuses))
 
 
         # Keep connection alive and handle incoming messages
         # Keep connection alive and handle incoming messages

+ 125 - 107
backend/app/main.py

@@ -5,6 +5,79 @@ from contextlib import asynccontextmanager
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
 from logging.handlers import RotatingFileHandler
 from logging.handlers import RotatingFileHandler
 
 
+from fastapi import FastAPI
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+from sqlalchemy import delete, or_, select
+
+from backend.app.api.routes import (
+    ams_history,
+    api_keys,
+    archives,
+    auth,
+    background_dispatch as background_dispatch_routes,
+    camera,
+    cloud,
+    discovery,
+    external_links,
+    filaments,
+    firmware,
+    github_backup,
+    groups,
+    inventory,
+    kprofiles,
+    library,
+    local_presets,
+    maintenance,
+    metrics,
+    notification_templates,
+    notifications,
+    pending_uploads,
+    print_log,
+    print_queue,
+    printers,
+    projects,
+    settings as settings_routes,
+    smart_plugs,
+    spoolman,
+    support,
+    system,
+    updates,
+    users,
+    virtual_printers,
+    webhook,
+    websocket,
+)
+from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
+from backend.app.api.routes.support import init_debug_logging
+from backend.app.core.config import APP_VERSION, settings as app_settings
+from backend.app.core.database import async_session, init_db
+from backend.app.core.websocket import ws_manager
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.services.archive import ArchiveService
+from backend.app.services.background_dispatch import background_dispatch
+from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
+from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.github_backup import github_backup_service
+from backend.app.services.homeassistant import homeassistant_service
+from backend.app.services.mqtt_relay import mqtt_relay
+from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
+from backend.app.services.notification_service import notification_service
+from backend.app.services.print_scheduler import scheduler as print_scheduler
+from backend.app.services.printer_manager import (
+    init_printer_connections,
+    printer_manager,
+    printer_state_to_dict,
+)
+from backend.app.services.smart_plug_manager import smart_plug_manager
+from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
+from backend.app.services.spoolman_tracking import (
+    cleanup_tracking as _cleanup_spoolman_tracking,
+    report_usage as _report_spoolman_usage,
+    store_print_data as _store_spoolman_print_data,
+)
+from backend.app.services.tasmota import tasmota_service
+
 
 
 # =============================================================================
 # =============================================================================
 # Dependency Check - runs before other imports to give helpful error messages
 # Dependency Check - runs before other imports to give helpful error messages
@@ -126,10 +199,8 @@ def check_dependencies():
 check_dependencies()
 check_dependencies()
 # =============================================================================
 # =============================================================================
 
 
-from fastapi import FastAPI
 
 
 # Import settings first for logging configuration
 # Import settings first for logging configuration
-from backend.app.core.config import APP_VERSION, settings as app_settings
 
 
 # Configure logging based on settings
 # Configure logging based on settings
 # DEBUG=true -> DEBUG level, else use LOG_LEVEL setting
 # DEBUG=true -> DEBUG level, else use LOG_LEVEL setting
@@ -169,74 +240,7 @@ if not app_settings.debug:
     logging.getLogger("paho.mqtt").setLevel(logging.WARNING)
     logging.getLogger("paho.mqtt").setLevel(logging.WARNING)
 
 
 logging.info("Bambuddy starting - debug=%s, log_level=%s", app_settings.debug, log_level_str)
 logging.info("Bambuddy starting - debug=%s, log_level=%s", app_settings.debug, log_level_str)
-from fastapi.responses import FileResponse
-from fastapi.staticfiles import StaticFiles
-from sqlalchemy import delete, or_, select
 
 
-from backend.app.api.routes import (
-    ams_history,
-    api_keys,
-    archives,
-    auth,
-    camera,
-    cloud,
-    discovery,
-    external_links,
-    filaments,
-    firmware,
-    github_backup,
-    groups,
-    inventory,
-    kprofiles,
-    library,
-    local_presets,
-    maintenance,
-    metrics,
-    notification_templates,
-    notifications,
-    pending_uploads,
-    print_log,
-    print_queue,
-    printers,
-    projects,
-    settings as settings_routes,
-    smart_plugs,
-    spoolman,
-    support,
-    system,
-    updates,
-    users,
-    virtual_printers,
-    webhook,
-    websocket,
-)
-from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
-from backend.app.api.routes.support import init_debug_logging
-from backend.app.core.database import async_session, init_db
-from backend.app.core.websocket import ws_manager
-from backend.app.models.smart_plug import SmartPlug
-from backend.app.services.archive import ArchiveService
-from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
-from backend.app.services.bambu_mqtt import PrinterState
-from backend.app.services.github_backup import github_backup_service
-from backend.app.services.homeassistant import homeassistant_service
-from backend.app.services.mqtt_relay import mqtt_relay
-from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
-from backend.app.services.notification_service import notification_service
-from backend.app.services.print_scheduler import scheduler as print_scheduler
-from backend.app.services.printer_manager import (
-    init_printer_connections,
-    printer_manager,
-    printer_state_to_dict,
-)
-from backend.app.services.smart_plug_manager import smart_plug_manager
-from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
-from backend.app.services.spoolman_tracking import (
-    cleanup_tracking as _cleanup_spoolman_tracking,
-    report_usage as _report_spoolman_usage,
-    store_print_data as _store_spoolman_print_data,
-)
-from backend.app.services.tasmota import tasmota_service
 
 
 # Track active prints: {(printer_id, filename): archive_id}
 # Track active prints: {(printer_id, filename): archive_id}
 _active_prints: dict[tuple[int, str], int] = {}
 _active_prints: dict[tuple[int, str], int] = {}
@@ -320,7 +324,8 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int, ams
 
 
 
 
 _last_status_broadcast: dict[int, str] = {}
 _last_status_broadcast: dict[int, str] = {}
-_nozzle_count_updated: set[int] = set()  # Track printers where we've updated nozzle_count
+# Track printers where we've updated nozzle_count
+_nozzle_count_updated: set[int] = set()
 
 
 
 
 async def on_printer_status_change(printer_id: int, state: PrinterState):
 async def on_printer_status_change(printer_id: int, state: PrinterState):
@@ -1050,10 +1055,10 @@ async def on_print_start(printer_id: int, data: dict):
             from backend.app.api.routes.settings import get_setting
             from backend.app.api.routes.settings import get_setting
 
 
             _spoolman_on = await get_setting(db, "spoolman_enabled")
             _spoolman_on = await get_setting(db, "spoolman_enabled")
-        if not _spoolman_on or _spoolman_on.lower() != "true":
-            from backend.app.services.usage_tracker import on_print_start as usage_on_print_start
+            if not _spoolman_on or _spoolman_on.lower() != "true":
+                from backend.app.services.usage_tracker import on_print_start as usage_on_print_start
 
 
-            await usage_on_print_start(printer_id, data, printer_manager)
+                await usage_on_print_start(printer_id, data, printer_manager, db=db)
     except Exception as e:
     except Exception as e:
         logger.warning("Usage tracker on_print_start failed: %s", e)
         logger.warning("Usage tracker on_print_start failed: %s", e)
 
 
@@ -2114,40 +2119,46 @@ async def on_print_complete(printer_id: int, data: dict):
     # auto-start files found in root on power cycle, causing ghost prints.
     # auto-start files found in root on power cycle, causing ghost prints.
     # Must run before the archive_id early-return so it executes even when archiving is disabled.
     # Must run before the archive_id early-return so it executes even when archiving is disabled.
     try:
     try:
-        printer_info = printer_manager.get_printer(printer_id)
-        if printer_info and subtask_name:
-            from backend.app.services.bambu_ftp import delete_file_async
-
-            # Try both .3mf and .gcode extensions — the printer may have either
-            for ext in (".3mf", ".gcode"):
-                remote_path = f"/{subtask_name}{ext}"
-                # Retry up to 3 times — the printer may still lock the filesystem briefly after a print ends
-                for attempt in range(1, 4):
-                    try:
-                        delete_result = await delete_file_async(
-                            printer_info.ip_address,
-                            printer_info.access_code,
-                            remote_path,
-                            printer_model=printer_info.model,
-                        )
-                        if delete_result:
-                            logger.info("Deleted %s from printer %s SD card", remote_path, printer_info.name)
-                        break  # Success or file doesn't exist — no need to retry
-                    except Exception as e:
-                        if attempt < 3:
-                            logger.debug(
-                                "SD card cleanup attempt %d/3 failed for %s: %s, retrying in 2s",
-                                attempt,
-                                remote_path,
-                                e,
-                            )
-                            await asyncio.sleep(2)
-                        else:
-                            logger.debug(
-                                "SD card cleanup failed after 3 attempts for %s: %s (non-critical)",
+        if subtask_name:
+            async with async_session() as db:
+                from backend.app.models.printer import Printer
+
+                result = await db.execute(select(Printer).where(Printer.id == printer_id))
+                printer = result.scalar_one_or_none()
+
+            if printer:
+                from backend.app.services.bambu_ftp import delete_file_async
+
+                # Try both .3mf and .gcode extensions — the printer may have either
+                for ext in (".3mf", ".gcode"):
+                    remote_path = f"/{subtask_name}{ext}"
+                    # Retry up to 3 times — the printer may still lock the filesystem briefly after a print ends
+                    for attempt in range(1, 4):
+                        try:
+                            delete_result = await delete_file_async(
+                                printer.ip_address,
+                                printer.access_code,
                                 remote_path,
                                 remote_path,
-                                e,
+                                printer_model=printer.model,
                             )
                             )
+                            if delete_result:
+                                logger.info("Deleted %s from printer %s SD card", remote_path, printer.name)
+                            break  # Success or file doesn't exist — no need to retry
+                        except Exception as e:
+                            if attempt < 3:
+                                logger.debug(
+                                    "SD card cleanup attempt %d/3 failed for %s: %s, retrying in 2s",
+                                    attempt,
+                                    remote_path,
+                                    e,
+                                )
+                                await asyncio.sleep(2)
+                            else:
+                                logger.debug(
+                                    "SD card cleanup failed after 3 attempts for %s: %s (non-critical)",
+                                    remote_path,
+                                    e,
+                                )
     except Exception as e:
     except Exception as e:
         logger.debug("SD card file cleanup failed for printer %s: %s (non-critical)", printer_id, e)
         logger.debug("SD card file cleanup failed for printer %s: %s (non-critical)", printer_id, e)
 
 
@@ -2869,7 +2880,8 @@ _ams_history_task: asyncio.Task | None = None
 AMS_HISTORY_INTERVAL = 300  # Record every 5 minutes
 AMS_HISTORY_INTERVAL = 300  # Record every 5 minutes
 AMS_HISTORY_RETENTION_DAYS = 30  # Keep data for 30 days
 AMS_HISTORY_RETENTION_DAYS = 30  # Keep data for 30 days
 _ams_cleanup_counter = 0  # Track recordings to trigger periodic cleanup
 _ams_cleanup_counter = 0  # Track recordings to trigger periodic cleanup
-_ams_alarm_cooldown: dict[str, datetime] = {}  # Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
+# Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
+_ams_alarm_cooldown: dict[str, datetime] = {}
 AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
 AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
 
 
 
 
@@ -3253,6 +3265,9 @@ async def lifespan(app: FastAPI):
     # Start the print scheduler
     # Start the print scheduler
     asyncio.create_task(print_scheduler.run())
     asyncio.create_task(print_scheduler.run())
 
 
+    # Start background dispatch worker for send/start operations
+    await background_dispatch.start()
+
     # Start the smart plug scheduler for time-based on/off
     # Start the smart plug scheduler for time-based on/off
     smart_plug_manager.start_scheduler()
     smart_plug_manager.start_scheduler()
 
 
@@ -3285,6 +3300,7 @@ async def lifespan(app: FastAPI):
 
 
     # Shutdown
     # Shutdown
     print_scheduler.stop()
     print_scheduler.stop()
+    await background_dispatch.stop()
     smart_plug_manager.stop_scheduler()
     smart_plug_manager.stop_scheduler()
     notification_service.stop_digest_scheduler()
     notification_service.stop_digest_scheduler()
     github_backup_service.stop_scheduler()
     github_backup_service.stop_scheduler()
@@ -3318,7 +3334,8 @@ PUBLIC_API_ROUTES = {
     "/api/v1/auth/status",
     "/api/v1/auth/status",
     "/api/v1/auth/login",
     "/api/v1/auth/login",
     "/api/v1/auth/setup",  # Needed for initial setup and recovery
     "/api/v1/auth/setup",  # Needed for initial setup and recovery
-    "/api/v1/auth/advanced-auth/status",  # Advanced auth status needed for login page
+    # Advanced auth status needed for login page
+    "/api/v1/auth/advanced-auth/status",
     "/api/v1/auth/forgot-password",  # Password reset for advanced auth
     "/api/v1/auth/forgot-password",  # Password reset for advanced auth
     # Version check for updates (no sensitive data)
     # Version check for updates (no sensitive data)
     "/api/v1/updates/version",
     "/api/v1/updates/version",
@@ -3470,6 +3487,7 @@ app.include_router(local_presets.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(print_log.router, prefix=app_settings.api_prefix)
 app.include_router(print_log.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
+app.include_router(background_dispatch_routes.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)
 app.include_router(notification_templates.router, prefix=app_settings.api_prefix)
 app.include_router(notification_templates.router, prefix=app_settings.api_prefix)

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

@@ -11,13 +11,15 @@ class ArchiveBase(BaseModel):
     cost: float | None = None
     cost: float | None = None
     failure_reason: str | None = None
     failure_reason: str | None = None
     quantity: int | None = None  # Number of items printed
     quantity: int | None = None  # Number of items printed
-    external_url: str | None = None  # User-defined link (Printables, Thingiverse, etc.)
+    # User-defined link (Printables, Thingiverse, etc.)
+    external_url: str | None = None
 
 
 
 
 class ArchiveUpdate(ArchiveBase):
 class ArchiveUpdate(ArchiveBase):
     printer_id: int | None = None
     printer_id: int | None = None
     project_id: int | None = None
     project_id: int | None = None
-    status: str | None = None  # Allow changing status (e.g., clearing failed flag)
+    # Allow changing status (e.g., clearing failed flag)
+    status: str | None = None
 
 
 
 
 class ArchiveDuplicate(BaseModel):
 class ArchiveDuplicate(BaseModel):
@@ -53,7 +55,8 @@ class ArchiveResponse(BaseModel):
     print_name: str | None
     print_name: str | None
     print_time_seconds: int | None  # Estimated time from slicer
     print_time_seconds: int | None  # Estimated time from slicer
     actual_time_seconds: int | None = None  # Computed from started_at/completed_at
     actual_time_seconds: int | None = None  # Computed from started_at/completed_at
-    time_accuracy: float | None = None  # Percentage: 100 = perfect, >100 = faster than estimated
+    # Percentage: 100 = perfect, >100 = faster than estimated
+    time_accuracy: float | None = None
     filament_used_grams: float | None
     filament_used_grams: float | None
     filament_type: str | None
     filament_type: str | None
     filament_color: str | None
     filament_color: str | None
@@ -73,7 +76,8 @@ class ArchiveResponse(BaseModel):
 
 
     makerworld_url: str | None
     makerworld_url: str | None
     designer: str | None
     designer: str | None
-    external_url: str | None = None  # User-defined link (Printables, Thingiverse, etc.)
+    # User-defined link (Printables, Thingiverse, etc.)
+    external_url: str | None = None
 
 
     is_favorite: bool
     is_favorite: bool
     tags: str | None
     tags: str | None
@@ -116,7 +120,8 @@ class ArchiveStats(BaseModel):
     prints_by_filament_type: dict
     prints_by_filament_type: dict
     prints_by_printer: dict
     prints_by_printer: dict
     # Time accuracy stats
     # Time accuracy stats
-    average_time_accuracy: float | None = None  # Average across all prints with data
+    # Average across all prints with data
+    average_time_accuracy: float | None = None
     time_accuracy_by_printer: dict | None = None  # Per-printer accuracy
     time_accuracy_by_printer: dict | None = None  # Per-printer accuracy
     # Energy stats
     # Energy stats
     total_energy_kwh: float = 0.0
     total_energy_kwh: float = 0.0
@@ -181,6 +186,7 @@ class ReprintRequest(BaseModel):
     # Plate selection for multi-plate 3MF files
     # Plate selection for multi-plate 3MF files
     # If not specified, auto-detects from file (legacy behavior for single-plate files)
     # If not specified, auto-detects from file (legacy behavior for single-plate files)
     plate_id: int | None = None
     plate_id: int | None = None
+    plate_name: str | None = None
 
 
     # AMS slot mapping: list of tray IDs for each filament slot in the 3MF
     # AMS slot mapping: list of tray IDs for each filament slot in the 3MF
     # Global tray ID = (ams_id * 4) + slot_id, external = 254
     # Global tray ID = (ams_id * 4) + slot_id, external = 254

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

@@ -181,6 +181,7 @@ class FilePrintRequest(BaseModel):
 
 
     # Print options (same as archive reprint)
     # Print options (same as archive reprint)
     plate_id: int | None = None
     plate_id: int | None = None
+    plate_name: str | None = None
     ams_mapping: list[int] | None = None
     ams_mapping: list[int] | None = None
     bed_levelling: bool = True
     bed_levelling: bool = True
     flow_cali: bool = False
     flow_cali: bool = False

+ 856 - 0
backend/app/services/background_dispatch.py

@@ -0,0 +1,856 @@
+"""Background dispatch for print/reprint jobs.
+
+This service is separate from the app's print queue feature. It exists only to
+decouple "send/start print" operations (FTP upload + start command) from API
+request latency so the UI can continue immediately after dispatch.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+import zipfile
+from collections import deque
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Literal
+
+from sqlalchemy import select
+
+from backend.app.core.config import settings
+from backend.app.core.database import async_session
+from backend.app.core.websocket import ws_manager
+from backend.app.models.library import LibraryFile
+from backend.app.models.printer import Printer
+from backend.app.services.archive import ArchiveService
+from backend.app.services.bambu_ftp import (
+    delete_file_async,
+    get_ftp_retry_settings,
+    upload_file_async,
+    with_ftp_retry,
+)
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+
+class DispatchJobCancelled(Exception):
+    """Raised when a dispatch job is cancelled by the user."""
+
+
+class DispatchEnqueueRejected(Exception):
+    """Raised when a dispatch job should not be accepted."""
+
+
+@dataclass(slots=True)
+class PrintDispatchJob:
+    id: int
+    kind: Literal["reprint_archive", "print_library_file"]
+    source_id: int
+    source_name: str
+    printer_id: int
+    printer_name: str
+    options: dict[str, Any] = field(default_factory=dict)
+    requested_by_user_id: int | None = None
+    requested_by_username: str | None = None
+
+
+@dataclass(slots=True)
+class ActiveDispatchState:
+    job: PrintDispatchJob
+    message: str
+    upload_bytes: int | None = None
+    upload_total_bytes: int | None = None
+
+
+class BackgroundDispatchService:
+    def __init__(self):
+        self._queued_jobs: deque[PrintDispatchJob] = deque()
+        self._dispatcher_task: asyncio.Task | None = None
+        self._running_tasks: dict[int, asyncio.Task] = {}
+        self._lock = asyncio.Lock()
+        self._job_event = asyncio.Event()
+        self._next_job_id = 1
+        self._active_jobs: dict[int, ActiveDispatchState] = {}
+        self._cancel_requested_job_ids: set[int] = set()
+
+        # Progress for the current "batch" (since queue became non-empty)
+        self._batch_total = 0
+        self._batch_completed = 0
+        self._batch_failed = 0
+
+    @staticmethod
+    def _printer_is_busy_printing(printer_id: int) -> bool:
+        state = printer_manager.get_status(printer_id)
+        if not state:
+            return False
+        return state.state in ("RUNNING", "PAUSE", "PAUSED") and bool(state.gcode_file)
+
+    async def start(self):
+        async with self._lock:
+            if self._dispatcher_task and not self._dispatcher_task.done():
+                return
+            self._dispatcher_task = asyncio.create_task(self._dispatcher_loop(), name="background-dispatch-dispatcher")
+            logger.info("Background dispatch dispatcher started")
+
+    async def stop(self):
+        dispatcher: asyncio.Task | None = None
+        running_tasks: list[asyncio.Task] = []
+        async with self._lock:
+            dispatcher = self._dispatcher_task
+            self._dispatcher_task = None
+            running_tasks = list(self._running_tasks.values())
+            self._running_tasks.clear()
+            self._active_jobs.clear()
+            self._queued_jobs.clear()
+            self._cancel_requested_job_ids.clear()
+            self._job_event.set()
+
+        if dispatcher:
+            dispatcher.cancel()
+        for task in running_tasks:
+            task.cancel()
+
+        if dispatcher:
+            try:
+                await dispatcher
+            except asyncio.CancelledError:
+                pass
+
+        if running_tasks:
+            await asyncio.gather(*running_tasks, return_exceptions=True)
+
+        logger.info("Background dispatch dispatcher stopped")
+
+    async def dispatch_reprint_archive(
+        self,
+        *,
+        archive_id: int,
+        archive_name: str,
+        printer_id: int,
+        printer_name: str,
+        options: dict[str, Any],
+        requested_by_user_id: int | None,
+        requested_by_username: str | None,
+    ) -> dict[str, Any]:
+        return await self._dispatch(
+            kind="reprint_archive",
+            source_id=archive_id,
+            source_name=archive_name,
+            printer_id=printer_id,
+            printer_name=printer_name,
+            options=options,
+            requested_by_user_id=requested_by_user_id,
+            requested_by_username=requested_by_username,
+        )
+
+    async def get_state(self) -> dict[str, Any]:
+        """Get current dispatch queue state snapshot for newly connected clients."""
+        async with self._lock:
+            return self._build_state_payload_unlocked()
+
+    async def dispatch_print_library_file(
+        self,
+        *,
+        file_id: int,
+        filename: str,
+        printer_id: int,
+        printer_name: str,
+        options: dict[str, Any],
+        requested_by_user_id: int | None,
+        requested_by_username: str | None,
+    ) -> dict[str, Any]:
+        return await self._dispatch(
+            kind="print_library_file",
+            source_id=file_id,
+            source_name=filename,
+            printer_id=printer_id,
+            printer_name=printer_name,
+            options=options,
+            requested_by_user_id=requested_by_user_id,
+            requested_by_username=requested_by_username,
+        )
+
+    async def cancel_job(self, job_id: int) -> dict[str, Any]:
+        """Cancel a queued dispatch job.
+
+        Queued jobs are removed immediately. Active jobs are cancelled
+        cooperatively and will stop at the next cancellation checkpoint.
+        """
+        async with self._lock:
+            # Check active jobs first
+            active_state = self._active_jobs.get(job_id)
+            if active_state is not None:
+                logger.info("Cancel requested for active dispatch job %s", job_id)
+                self._cancel_requested_job_ids.add(job_id)
+                active_job = active_state.job
+                payload = self._build_state_payload_unlocked(
+                    recent_event={
+                        "status": "cancelling",
+                        "job_id": active_job.id,
+                        "source_name": active_job.source_name,
+                        "printer_id": active_job.printer_id,
+                        "printer_name": active_job.printer_name,
+                        "message": "Cancelling current dispatch...",
+                    }
+                )
+                result = {
+                    "cancelled": True,
+                    "pending": True,
+                    "job_id": active_job.id,
+                    "source_name": active_job.source_name,
+                    "printer_id": active_job.printer_id,
+                    "printer_name": active_job.printer_name,
+                }
+                await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+                return result
+
+            # Check queued jobs
+            cancelled_job: PrintDispatchJob | None = None
+            for job in self._queued_jobs:
+                if job.id == job_id:
+                    cancelled_job = job
+                    break
+
+            if not cancelled_job:
+                logger.info("Cancel requested for unknown dispatch job %s", job_id)
+                return {"cancelled": False, "reason": "not_found"}
+
+            self._queued_jobs.remove(cancelled_job)
+            logger.info("Cancelled queued dispatch job %s", cancelled_job.id)
+            self._batch_total = max(0, self._batch_total - 1)
+
+            if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
+                self._batch_completed = 0
+                self._batch_failed = 0
+
+            payload = self._build_state_payload_unlocked(
+                recent_event={
+                    "status": "cancelled",
+                    "job_id": cancelled_job.id,
+                    "source_name": cancelled_job.source_name,
+                    "printer_id": cancelled_job.printer_id,
+                    "printer_name": cancelled_job.printer_name,
+                    "message": "Cancelled from queue",
+                }
+            )
+
+        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+        return {
+            "cancelled": True,
+            "pending": False,
+            "job_id": cancelled_job.id,
+            "source_name": cancelled_job.source_name,
+            "printer_id": cancelled_job.printer_id,
+            "printer_name": cancelled_job.printer_name,
+        }
+
+    async def _dispatch(
+        self,
+        *,
+        kind: Literal["reprint_archive", "print_library_file"],
+        source_id: int,
+        source_name: str,
+        printer_id: int,
+        printer_name: str,
+        options: dict[str, Any],
+        requested_by_user_id: int | None,
+        requested_by_username: str | None,
+    ) -> dict[str, Any]:
+        async with self._lock:
+            has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs)
+            has_active_for_printer = any(active.job.printer_id == printer_id for active in self._active_jobs.values())
+
+            if has_pending_for_printer or has_active_for_printer:
+                raise DispatchEnqueueRejected(f"Printer {printer_name} already has a background dispatch in progress")
+
+            if self._printer_is_busy_printing(printer_id):
+                raise DispatchEnqueueRejected(f"Printer {printer_name} is currently busy printing")
+
+            dispatch_position = len(self._queued_jobs) + len(self._active_jobs) + 1
+            job = PrintDispatchJob(
+                id=self._next_job_id,
+                kind=kind,
+                source_id=source_id,
+                source_name=source_name,
+                printer_id=printer_id,
+                printer_name=printer_name,
+                options=options,
+                requested_by_user_id=requested_by_user_id,
+                requested_by_username=requested_by_username,
+            )
+            self._next_job_id += 1
+            self._batch_total += 1
+            self._queued_jobs.append(job)
+            self._job_event.set()
+
+            payload = self._build_state_payload_unlocked(
+                recent_event={
+                    "status": "dispatched",
+                    "job_id": job.id,
+                    "source_name": source_name,
+                    "printer_id": printer_id,
+                    "printer_name": printer_name,
+                    "message": f"Dispatched to {printer_name}",
+                }
+            )
+
+        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+
+        return {
+            "dispatch_job_id": job.id,
+            "dispatch_position": dispatch_position,
+            "status": "dispatched",
+            "printer_id": printer_id,
+            "source_id": source_id,
+            "source_name": source_name,
+        }
+
+    async def _dispatcher_loop(self):
+        while True:
+            await self._job_event.wait()
+            self._job_event.clear()
+
+            while True:
+                payload: dict[str, Any] | None = None
+                job_to_start: PrintDispatchJob | None = None
+                async with self._lock:
+                    busy_printer_ids = {state.job.printer_id for state in self._active_jobs.values()}
+                    start_index = next(
+                        (
+                            idx
+                            for idx, queued_job in enumerate(self._queued_jobs)
+                            if queued_job.printer_id not in busy_printer_ids
+                        ),
+                        None,
+                    )
+
+                    if start_index is None:
+                        break
+
+                    job_to_start = self._queued_jobs[start_index]
+                    del self._queued_jobs[start_index]
+                    self._active_jobs[job_to_start.id] = ActiveDispatchState(
+                        job=job_to_start,
+                        message="Preparing background dispatch...",
+                    )
+
+                    task = asyncio.create_task(
+                        self._run_active_job(job_to_start), name=f"background-dispatch-job-{job_to_start.id}"
+                    )
+                    self._running_tasks[job_to_start.id] = task
+
+                    payload = self._build_state_payload_unlocked(
+                        recent_event={
+                            "status": "processing",
+                            "job_id": job_to_start.id,
+                            "source_name": job_to_start.source_name,
+                            "printer_id": job_to_start.printer_id,
+                            "printer_name": job_to_start.printer_name,
+                            "message": "Preparing background dispatch...",
+                        }
+                    )
+
+                if payload:
+                    await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+
+    async def _run_active_job(self, job: PrintDispatchJob):
+        try:
+            await self._process_job(job)
+            await self._mark_job_finished(job, failed=False, message="Background dispatch complete")
+        except DispatchJobCancelled:
+            await self._mark_job_cancelled(job)
+        except asyncio.CancelledError:
+            raise
+        except Exception as e:
+            logger.error("Background dispatch job %s failed: %s", job.id, e, exc_info=True)
+            await self._mark_job_finished(job, failed=True, message=str(e))
+        finally:
+            self._job_event.set()
+
+    async def _set_active_message(self, job: PrintDispatchJob, message: str):
+        async with self._lock:
+            active = self._active_jobs.get(job.id)
+            if not active:
+                return
+            active.message = message
+            payload = self._build_state_payload_unlocked(
+                recent_event={
+                    "status": "processing",
+                    "job_id": active.job.id,
+                    "source_name": active.job.source_name,
+                    "printer_id": active.job.printer_id,
+                    "printer_name": active.job.printer_name,
+                    "message": message,
+                }
+            )
+        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+
+    async def _set_active_upload_progress(self, job: PrintDispatchJob, uploaded: int, total: int):
+        async with self._lock:
+            active = self._active_jobs.get(job.id)
+            if not active:
+                return
+
+            active.upload_bytes = max(0, int(uploaded))
+            active.upload_total_bytes = max(0, int(total))
+            payload = self._build_state_payload_unlocked(
+                recent_event={
+                    "status": "processing",
+                    "job_id": active.job.id,
+                    "source_name": active.job.source_name,
+                    "printer_id": active.job.printer_id,
+                    "printer_name": active.job.printer_name,
+                    "message": active.message,
+                }
+            )
+        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+
+    async def _mark_job_finished(self, job: PrintDispatchJob, *, failed: bool, message: str):
+        async with self._lock:
+            if failed:
+                self._batch_failed += 1
+            else:
+                self._batch_completed += 1
+
+            self._active_jobs.pop(job.id, None)
+            self._running_tasks.pop(job.id, None)
+            self._cancel_requested_job_ids.discard(job.id)
+
+            payload = self._build_state_payload_unlocked(
+                recent_event={
+                    "status": "failed" if failed else "completed",
+                    "job_id": job.id,
+                    "source_name": job.source_name,
+                    "printer_id": job.printer_id,
+                    "printer_name": job.printer_name,
+                    "message": message,
+                }
+            )
+            should_reset_batch = len(self._queued_jobs) == 0 and len(self._active_jobs) == 0
+
+        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+
+        if should_reset_batch:
+            async with self._lock:
+                if len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
+                    self._batch_total = 0
+                    self._batch_completed = 0
+                    self._batch_failed = 0
+
+    async def _mark_job_cancelled(self, job: PrintDispatchJob):
+        async with self._lock:
+            self._active_jobs.pop(job.id, None)
+            self._running_tasks.pop(job.id, None)
+            self._cancel_requested_job_ids.discard(job.id)
+            self._batch_total = max(0, self._batch_total - 1)
+
+            if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
+                self._batch_completed = 0
+                self._batch_failed = 0
+
+            payload = self._build_state_payload_unlocked(
+                recent_event={
+                    "status": "cancelled",
+                    "job_id": job.id,
+                    "source_name": job.source_name,
+                    "printer_id": job.printer_id,
+                    "printer_name": job.printer_name,
+                    "message": "Cancelled during dispatch",
+                }
+            )
+
+        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+
+    def _is_cancel_requested(self, job_id: int) -> bool:
+        return job_id in self._cancel_requested_job_ids
+
+    def _raise_if_cancel_requested(self, job: PrintDispatchJob):
+        if self._is_cancel_requested(job.id):
+            raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled")
+
+    def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:
+        processing = len(self._active_jobs)
+        dispatched = len(self._queued_jobs)
+
+        dispatched_jobs = [
+            {
+                "job_id": job.id,
+                "kind": job.kind,
+                "source_id": job.source_id,
+                "source_name": job.source_name,
+                "printer_id": job.printer_id,
+                "printer_name": job.printer_name,
+            }
+            for job in list(self._queued_jobs)
+        ]
+
+        active_jobs: list[dict[str, Any]] = []
+        for active in self._active_jobs.values():
+            upload_progress_pct = None
+            if active.upload_total_bytes and active.upload_total_bytes > 0 and active.upload_bytes is not None:
+                upload_progress_pct = round(
+                    max(0.0, min(100.0, (active.upload_bytes / active.upload_total_bytes) * 100.0)), 1
+                )
+
+            active_jobs.append(
+                {
+                    "job_id": active.job.id,
+                    "kind": active.job.kind,
+                    "source_id": active.job.source_id,
+                    "source_name": active.job.source_name,
+                    "printer_id": active.job.printer_id,
+                    "printer_name": active.job.printer_name,
+                    "message": active.message,
+                    "upload_bytes": active.upload_bytes,
+                    "upload_total_bytes": active.upload_total_bytes,
+                    "upload_progress_pct": upload_progress_pct,
+                }
+            )
+
+        active_jobs.sort(key=lambda item: int(item["job_id"]))
+        active_job = active_jobs[0] if active_jobs else None
+
+        return {
+            "total": self._batch_total,
+            "dispatched": dispatched,
+            "processing": processing,
+            "completed": self._batch_completed,
+            "failed": self._batch_failed,
+            "dispatched_jobs": dispatched_jobs,
+            "active_jobs": active_jobs,
+            "active_job": active_job,
+            "recent_event": recent_event,
+        }
+
+    async def _process_job(self, job: PrintDispatchJob):
+        if job.kind == "reprint_archive":
+            await self._run_reprint_archive(job)
+            return
+        if job.kind == "print_library_file":
+            await self._run_print_library_file(job)
+            return
+        raise RuntimeError(f"Unknown dispatch job kind: {job.kind}")
+
+    async def _run_reprint_archive(self, job: PrintDispatchJob):
+        from backend.app.main import register_expected_print
+
+        async with async_session() as db:
+            service = ArchiveService(db)
+            archive = await service.get_archive(job.source_id)
+            if not archive:
+                raise RuntimeError("Archive not found")
+
+            printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
+            if not printer:
+                raise RuntimeError("Printer not found")
+
+            printer_name = printer.name
+            printer_ip = printer.ip_address
+            printer_access_code = printer.access_code
+            printer_model = printer.model
+            archive_filename = archive.filename
+
+            if not printer_manager.is_connected(job.printer_id):
+                raise RuntimeError("Printer is not connected")
+
+            file_path = settings.base_dir / archive.file_path
+            if not file_path.exists():
+                raise RuntimeError("Archive file not found")
+
+            base_name = archive.filename
+            if base_name.endswith(".gcode.3mf"):
+                base_name = base_name[:-10]
+            elif base_name.endswith(".3mf"):
+                base_name = base_name[:-4]
+            remote_filename = f"{base_name}.3mf"
+            remote_path = f"/{remote_filename}"
+
+            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+            self._raise_if_cancel_requested(job)
+
+            await self._set_active_message(job, f"Preparing upload to {printer_name}...")
+            await delete_file_async(
+                printer_ip,
+                printer_access_code,
+                remote_path,
+                socket_timeout=ftp_timeout,
+                printer_model=printer_model,
+            )
+
+            self._raise_if_cancel_requested(job)
+
+            try:
+                await self._set_active_message(job, f"Uploading {archive_filename} to {printer_name}...")
+                loop = asyncio.get_running_loop()
+                progress_state = {"last_emit": 0.0, "last_bytes": 0}
+
+                def upload_progress_callback(uploaded: int, total: int):
+                    if self._is_cancel_requested(job.id):
+                        raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
+
+                    now = time.monotonic()
+                    should_emit = (
+                        uploaded >= total
+                        or now - progress_state["last_emit"] >= 0.2
+                        or uploaded - progress_state["last_bytes"] >= 256 * 1024
+                    )
+
+                    if should_emit:
+                        progress_state["last_emit"] = now
+                        progress_state["last_bytes"] = uploaded
+                        loop.call_soon_threadsafe(
+                            lambda u=uploaded, t=total: asyncio.create_task(self._set_active_upload_progress(job, u, t))
+                        )
+
+                if ftp_retry_enabled:
+                    uploaded = await with_ftp_retry(
+                        upload_file_async,
+                        printer_ip,
+                        printer_access_code,
+                        file_path,
+                        remote_path,
+                        progress_callback=upload_progress_callback,
+                        socket_timeout=ftp_timeout,
+                        printer_model=printer_model,
+                        max_retries=ftp_retry_count,
+                        retry_delay=ftp_retry_delay,
+                        operation_name=f"Upload for reprint to {printer_name}",
+                        non_retry_exceptions=(DispatchJobCancelled,),
+                    )
+                else:
+                    uploaded = await upload_file_async(
+                        printer_ip,
+                        printer_access_code,
+                        file_path,
+                        remote_path,
+                        progress_callback=upload_progress_callback,
+                        socket_timeout=ftp_timeout,
+                        printer_model=printer_model,
+                    )
+
+                if uploaded:
+                    await self._set_active_upload_progress(job, 1, 1)
+
+                if not uploaded:
+                    raise RuntimeError(
+                        "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
+                    )
+
+                register_expected_print(
+                    job.printer_id,
+                    remote_filename,
+                    job.source_id,
+                    ams_mapping=job.options.get("ams_mapping"),
+                )
+
+                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
+
+                self._raise_if_cancel_requested(job)
+
+                await self._set_active_message(job, f"Starting print on {printer_name}...")
+                started = printer_manager.start_print(
+                    job.printer_id,
+                    remote_filename,
+                    plate_id,
+                    ams_mapping=job.options.get("ams_mapping"),
+                    timelapse=job.options.get("timelapse", False),
+                    bed_levelling=job.options.get("bed_levelling", True),
+                    flow_cali=job.options.get("flow_cali", False),
+                    vibration_cali=job.options.get("vibration_cali", False),
+                    layer_inspect=job.options.get("layer_inspect", False),
+                    use_ams=job.options.get("use_ams", True),
+                )
+
+                if not started:
+                    raise RuntimeError("Failed to start print")
+
+                if job.requested_by_user_id and job.requested_by_username:
+                    printer_manager.set_current_print_user(
+                        job.printer_id,
+                        job.requested_by_user_id,
+                        job.requested_by_username,
+                    )
+            except DispatchJobCancelled:
+                await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
+                raise
+
+    async def _run_print_library_file(self, job: PrintDispatchJob):
+        from backend.app.main import register_expected_print
+
+        async with async_session() as db:
+            lib_file = await db.scalar(select(LibraryFile).where(LibraryFile.id == job.source_id))
+            if not lib_file:
+                raise RuntimeError("File not found")
+
+            if not self._is_sliced_file(lib_file.filename):
+                raise RuntimeError("Not a sliced file. Only .gcode or .gcode.3mf files can be printed.")
+
+            file_path = Path(settings.base_dir) / lib_file.file_path
+            if not file_path.exists():
+                raise RuntimeError("File not found on disk")
+
+            printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
+            if not printer:
+                raise RuntimeError("Printer not found")
+
+            printer_name = printer.name
+            printer_ip = printer.ip_address
+            printer_access_code = printer.access_code
+            printer_model = printer.model
+            library_filename = lib_file.filename
+
+            if not printer_manager.is_connected(job.printer_id):
+                raise RuntimeError("Printer is not connected")
+
+            await self._set_active_message(job, f"Creating archive for {lib_file.filename}...")
+            archive_service = ArchiveService(db)
+            archive = await archive_service.archive_print(
+                printer_id=job.printer_id,
+                source_file=file_path,
+            )
+            if not archive:
+                raise RuntimeError("Failed to create archive")
+
+            await db.flush()
+
+            base_name = lib_file.filename
+            if base_name.endswith(".gcode.3mf"):
+                base_name = base_name[:-10]
+            elif base_name.endswith(".3mf"):
+                base_name = base_name[:-4]
+            remote_filename = f"{base_name}.3mf"
+            remote_path = f"/{remote_filename}"
+
+            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+            self._raise_if_cancel_requested(job)
+
+            await self._set_active_message(job, f"Preparing upload to {printer_name}...")
+            await delete_file_async(
+                printer_ip,
+                printer_access_code,
+                remote_path,
+                socket_timeout=ftp_timeout,
+                printer_model=printer_model,
+            )
+
+            self._raise_if_cancel_requested(job)
+
+            try:
+                await self._set_active_message(job, f"Uploading {library_filename} to {printer_name}...")
+                loop = asyncio.get_running_loop()
+                progress_state = {"last_emit": 0.0, "last_bytes": 0}
+
+                def upload_progress_callback(uploaded: int, total: int):
+                    if self._is_cancel_requested(job.id):
+                        raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
+
+                    now = time.monotonic()
+                    should_emit = (
+                        uploaded >= total
+                        or now - progress_state["last_emit"] >= 0.2
+                        or uploaded - progress_state["last_bytes"] >= 256 * 1024
+                    )
+
+                    if should_emit:
+                        progress_state["last_emit"] = now
+                        progress_state["last_bytes"] = uploaded
+                        loop.call_soon_threadsafe(
+                            lambda u=uploaded, t=total: asyncio.create_task(self._set_active_upload_progress(job, u, t))
+                        )
+
+                if ftp_retry_enabled:
+                    uploaded = await with_ftp_retry(
+                        upload_file_async,
+                        printer_ip,
+                        printer_access_code,
+                        file_path,
+                        remote_path,
+                        progress_callback=upload_progress_callback,
+                        socket_timeout=ftp_timeout,
+                        printer_model=printer_model,
+                        max_retries=ftp_retry_count,
+                        retry_delay=ftp_retry_delay,
+                        operation_name=f"Upload for print to {printer_name}",
+                        non_retry_exceptions=(DispatchJobCancelled,),
+                    )
+                else:
+                    uploaded = await upload_file_async(
+                        printer_ip,
+                        printer_access_code,
+                        file_path,
+                        remote_path,
+                        progress_callback=upload_progress_callback,
+                        socket_timeout=ftp_timeout,
+                        printer_model=printer_model,
+                    )
+
+                if uploaded:
+                    await self._set_active_upload_progress(job, 1, 1)
+
+                if not uploaded:
+                    await db.rollback()
+                    raise RuntimeError(
+                        "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
+                    )
+
+                register_expected_print(
+                    job.printer_id,
+                    remote_filename,
+                    archive.id,
+                    ams_mapping=job.options.get("ams_mapping"),
+                )
+
+                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
+
+                self._raise_if_cancel_requested(job)
+
+                await self._set_active_message(job, f"Starting print on {printer_name}...")
+                started = printer_manager.start_print(
+                    job.printer_id,
+                    remote_filename,
+                    plate_id,
+                    ams_mapping=job.options.get("ams_mapping"),
+                    timelapse=job.options.get("timelapse", False),
+                    bed_levelling=job.options.get("bed_levelling", True),
+                    flow_cali=job.options.get("flow_cali", False),
+                    vibration_cali=job.options.get("vibration_cali", False),
+                    layer_inspect=job.options.get("layer_inspect", False),
+                    use_ams=job.options.get("use_ams", True),
+                )
+
+                if not started:
+                    await db.rollback()
+                    raise RuntimeError("Failed to start print")
+
+                await db.commit()
+            except DispatchJobCancelled:
+                await db.rollback()
+                await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
+                raise
+
+    @staticmethod
+    def _resolve_plate_id(file_path: Path, requested_plate_id: int | None) -> int:
+        if requested_plate_id is not None:
+            return requested_plate_id
+
+        plate_id = 1
+        try:
+            with zipfile.ZipFile(file_path, "r") as zf:
+                for name in zf.namelist():
+                    if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
+                        plate_str = name[15:-6]
+                        plate_id = int(plate_str)
+                        break
+        except (ValueError, zipfile.BadZipFile, OSError):
+            pass
+        return plate_id
+
+    @staticmethod
+    def _is_sliced_file(filename: str) -> bool:
+        lower = filename.lower()
+        return lower.endswith(".gcode") or lower.endswith(".gcode.3mf")
+
+
+background_dispatch = BackgroundDispatchService()

+ 56 - 9
backend/app/services/bambu_ftp.py

@@ -76,13 +76,16 @@ class BambuFTPClient:
     """FTP client for retrieving files from Bambu Lab printers."""
     """FTP client for retrieving files from Bambu Lab printers."""
 
 
     FTP_PORT = 990
     FTP_PORT = 990
-    DEFAULT_TIMEOUT = 30  # Default timeout in seconds (increased for A1 printers)
+    # Default timeout in seconds (increased for A1 printers)
+    DEFAULT_TIMEOUT = 30
     # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
     # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
     # These models have varying FTP SSL behavior depending on firmware version
     # These models have varying FTP SSL behavior depending on firmware version
     A1_MODELS = ("A1", "A1 Mini")
     A1_MODELS = ("A1", "A1 Mini")
     # Chunk size for manual upload transfer (1MB)
     # Chunk size for manual upload transfer (1MB)
     # Larger chunks reduce overhead and work better with A1 printers
     # Larger chunks reduce overhead and work better with A1 printers
     CHUNK_SIZE = 1024 * 1024
     CHUNK_SIZE = 1024 * 1024
+    # Per-chunk data socket timeout during upload.
+    UPLOAD_CHUNK_TIMEOUT = 120
 
 
     # Cache for working FTP modes per printer IP
     # Cache for working FTP modes per printer IP
     # Maps IP -> "prot_p" or "prot_c"
     # Maps IP -> "prot_p" or "prot_c"
@@ -359,6 +362,7 @@ class BambuFTPClient:
             logger.info("FTP uploading %s (%s bytes) to %s", local_path, file_size, remote_path)
             logger.info("FTP uploading %s (%s bytes) to %s", local_path, file_size, remote_path)
 
 
             uploaded = 0
             uploaded = 0
+            callback_exception: Exception | None = None
 
 
             # Use manual transfer instead of storbinary() for A1 compatibility
             # Use manual transfer instead of storbinary() for A1 compatibility
             # A1 printers have issues with storbinary's voidresp() hanging after transfer
             # A1 printers have issues with storbinary's voidresp() hanging after transfer
@@ -368,7 +372,7 @@ class BambuFTPClient:
 
 
                 # Set explicit socket options for reliable transfer
                 # Set explicit socket options for reliable transfer
                 conn.setblocking(True)
                 conn.setblocking(True)
-                conn.settimeout(120)  # 2 minute timeout per chunk
+                conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
 
 
                 try:
                 try:
                     while True:
                     while True:
@@ -382,14 +386,51 @@ class BambuFTPClient:
                         logger.debug("FTP upload progress: %s/%s bytes", uploaded, file_size)
                         logger.debug("FTP upload progress: %s/%s bytes", uploaded, file_size)
 
 
                         if progress_callback:
                         if progress_callback:
-                            progress_callback(uploaded, file_size)
+                            try:
+                                progress_callback(uploaded, file_size)
+                            except Exception as e:
+                                callback_exception = e
+                                logger.info(
+                                    "FTP upload callback requested stop for %s at %s/%s bytes: %s",
+                                    remote_path,
+                                    uploaded,
+                                    file_size,
+                                    e,
+                                )
+                                break
 
 
                 except OSError as e:
                 except OSError as e:
                     logger.error("FTP connection lost during upload: %s", e)
                     logger.error("FTP connection lost during upload: %s", e)
-                    conn.close()
                     raise
                     raise
+                finally:
+                    try:
+                        conn.close()
+                    except OSError:
+                        pass
+
+            # Skip voidresp() for A1 models — they hang after transfercmd uploads
+            if self.printer_model not in self.A1_MODELS:
+                try:
+                    self._ftp.voidresp()
+                except (OSError, ftplib.Error) as e:
+                    # Data transfer already completed — voidresp() failure is just a noisy
+                    # 226 acknowledgment issue, not an actual upload failure. Log and continue.
+                    logger.warning("FTP upload response for %s was not clean (data already sent): %s", remote_path, e)
+
+            if callback_exception is not None:
+                cleanup_ok = False
+                try:
+                    cleanup_ok = self.delete_file(remote_path)
+                except Exception as cleanup_error:
+                    logger.warning("FTP cancel cleanup failed for %s: %s", remote_path, cleanup_error)
+
+                if cleanup_ok:
+                    logger.info("FTP cancel cleanup succeeded for %s", remote_path)
+                    raise callback_exception
 
 
-                conn.close()
+                raise RuntimeError(
+                    f"Upload cancelled but failed to remove partial file {remote_path} from printer"
+                ) from callback_exception
 
 
             logger.info("FTP upload complete: %s", remote_path)
             logger.info("FTP upload complete: %s", remote_path)
             return True
             return True
@@ -421,7 +462,7 @@ class BambuFTPClient:
             # Use manual transfer instead of storbinary() for A1 compatibility
             # Use manual transfer instead of storbinary() for A1 compatibility
             conn = self._ftp.transfercmd(f"STOR {remote_path}")
             conn = self._ftp.transfercmd(f"STOR {remote_path}")
             conn.setblocking(True)
             conn.setblocking(True)
-            conn.settimeout(120)
+            conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
 
 
             try:
             try:
                 # Send data in chunks
                 # Send data in chunks
@@ -432,10 +473,12 @@ class BambuFTPClient:
                     offset += len(chunk)
                     offset += len(chunk)
             except OSError as e:
             except OSError as e:
                 logger.error("FTP connection lost during upload_bytes: %s", e)
                 logger.error("FTP connection lost during upload_bytes: %s", e)
-                conn.close()
                 raise
                 raise
-
-            conn.close()
+            finally:
+                try:
+                    conn.close()
+                except OSError:
+                    pass
             return True
             return True
         except (OSError, ftplib.Error):
         except (OSError, ftplib.Error):
             return False
             return False
@@ -827,6 +870,7 @@ async def with_ftp_retry(
     max_retries: int = 3,
     max_retries: int = 3,
     retry_delay: float = 2.0,
     retry_delay: float = 2.0,
     operation_name: str = "FTP operation",
     operation_name: str = "FTP operation",
+    non_retry_exceptions: tuple[type[BaseException], ...] = (),
     **kwargs,
     **kwargs,
 ) -> T | None:
 ) -> T | None:
     """Execute FTP operation with retry logic.
     """Execute FTP operation with retry logic.
@@ -837,6 +881,7 @@ async def with_ftp_retry(
         max_retries: Number of retry attempts (default: 3)
         max_retries: Number of retry attempts (default: 3)
         retry_delay: Seconds to wait between retries (default: 2.0)
         retry_delay: Seconds to wait between retries (default: 2.0)
         operation_name: Name for logging purposes
         operation_name: Name for logging purposes
+        non_retry_exceptions: Exception types that should immediately abort retries
         **kwargs: Keyword arguments for the operation
         **kwargs: Keyword arguments for the operation
 
 
     Returns:
     Returns:
@@ -856,6 +901,8 @@ async def with_ftp_retry(
             if attempt > 0:
             if attempt > 0:
                 logger.info("%s attempt %s/%s returned failure", operation_name, attempt + 1, max_retries + 1)
                 logger.info("%s attempt %s/%s returned failure", operation_name, attempt + 1, max_retries + 1)
         except Exception as e:
         except Exception as e:
+            if non_retry_exceptions and isinstance(e, non_retry_exceptions):
+                raise
             last_error = e
             last_error = e
             logger.warning("%s attempt %s/%s failed: %s", operation_name, attempt + 1, max_retries + 1, e)
             logger.warning("%s attempt %s/%s failed: %s", operation_name, attempt + 1, max_retries + 1, e)
 
 

+ 6 - 3
backend/app/services/bambu_mqtt.py

@@ -2695,13 +2695,16 @@ class BambuMQTTClient:
 
 
         # Signal that we received the response (only if we were waiting for one)
         # Signal that we received the response (only if we were waiting for one)
         # Use thread-safe method since MQTT callbacks run in a different thread
         # Use thread-safe method since MQTT callbacks run in a different thread
-        if self._pending_kprofile_response:
+        # Capture in local var to avoid TOCTOU race: asyncio thread can clear
+        # self._pending_kprofile_response between the check and the .set() call
+        event = self._pending_kprofile_response
+        if event:
             logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
             logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
             if self._loop and self._loop.is_running():
             if self._loop and self._loop.is_running():
-                self._loop.call_soon_threadsafe(self._pending_kprofile_response.set)
+                self._loop.call_soon_threadsafe(event.set)
             else:
             else:
                 # Fallback for when loop is not available
                 # Fallback for when loop is not available
-                self._pending_kprofile_response.set()
+                event.set()
 
 
     async def get_kprofiles(
     async def get_kprofiles(
         self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3
         self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3

+ 2 - 2
backend/app/services/print_scheduler.py

@@ -512,14 +512,14 @@ class PrintScheduler:
         # Parse AMS units from raw_data
         # Parse AMS units from raw_data
         ams_data = status.raw_data.get("ams", [])
         ams_data = status.raw_data.get("ams", [])
         for ams_unit in ams_data:
         for ams_unit in ams_data:
-            ams_id = ams_unit.get("id", 0)
+            ams_id = int(ams_unit.get("id", 0))
             trays = ams_unit.get("tray", [])
             trays = ams_unit.get("tray", [])
             is_ht = len(trays) == 1  # AMS-HT has single tray
             is_ht = len(trays) == 1  # AMS-HT has single tray
 
 
             for tray in trays:
             for tray in trays:
                 tray_type = tray.get("tray_type")
                 tray_type = tray.get("tray_type")
                 if tray_type:
                 if tray_type:
-                    tray_id = tray.get("id", 0)
+                    tray_id = int(tray.get("id", 0))
                     tray_color = tray.get("tray_color", "")
                     tray_color = tray.get("tray_color", "")
                     # tray_info_idx identifies the specific spool (e.g., "GFA00", "P4d64437")
                     # tray_info_idx identifies the specific spool (e.g., "GFA00", "P4d64437")
                     tray_info_idx = tray.get("tray_info_idx", "")
                     tray_info_idx = tray.get("tray_info_idx", "")

+ 53 - 25
backend/app/services/usage_tracker.py

@@ -155,14 +155,17 @@ class PrintSession:
     tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
     tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
     # tray_now at print start (correct value, unlike at completion where it's 255)
     # tray_now at print start (correct value, unlike at completion where it's 255)
     tray_now_at_start: int = -1
     tray_now_at_start: int = -1
+    # Snapshot of spool assignments at print start: {(ams_id, tray_id): spool_id}
+    # Prevents usage loss when on_ams_change unlinks a spool mid-print
+    spool_assignments: dict[tuple[int, int], int] = field(default_factory=dict)
 
 
 
 
 # Module-level storage, keyed by printer_id
 # Module-level storage, keyed by printer_id
 _active_sessions: dict[int, PrintSession] = {}
 _active_sessions: dict[int, PrintSession] = {}
 
 
 
 
-async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
-    """Capture AMS tray remain% at print start."""
+async def on_print_start(printer_id: int, data: dict, printer_manager, db: AsyncSession | None = None) -> None:
+    """Capture AMS tray remain% and spool assignments at print start."""
     state = printer_manager.get_status(printer_id)
     state = printer_manager.get_status(printer_id)
     if not state or not state.raw_data:
     if not state or not state.raw_data:
         logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
         logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
@@ -215,6 +218,20 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
             )
             )
         logger.info("[UsageTracker] PRINT START printer %d AMS %d: %s", printer_id, ams_id, ", ".join(tray_summary))
         logger.info("[UsageTracker] PRINT START printer %d AMS %d: %s", printer_id, ams_id, ", ".join(tray_summary))
 
 
+    # Snapshot spool assignments so usage isn't lost if on_ams_change unlinks mid-print
+    spool_assignments: dict[tuple[int, int], int] = {}
+    if db:
+        assign_result = await db.execute(select(SpoolAssignment).where(SpoolAssignment.printer_id == printer_id))
+        for assignment in assign_result.scalars().all():
+            spool_assignments[(assignment.ams_id, assignment.tray_id)] = assignment.spool_id
+        if spool_assignments:
+            logger.info(
+                "[UsageTracker] Snapshotted %d spool assignments for printer %d: %s",
+                len(spool_assignments),
+                printer_id,
+                {f"{k[0]}-{k[1]}": v for k, v in spool_assignments.items()},
+            )
+
     # Always create session (even without valid remain data) so print_name
     # Always create session (even without valid remain data) so print_name
     # is available at completion for 3MF-based tracking
     # is available at completion for 3MF-based tracking
     session = PrintSession(
     session = PrintSession(
@@ -223,6 +240,7 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
         started_at=datetime.now(timezone.utc),
         started_at=datetime.now(timezone.utc),
         tray_remain_start=tray_remain_start,
         tray_remain_start=tray_remain_start,
         tray_now_at_start=tray_now_at_start,
         tray_now_at_start=tray_now_at_start,
+        spool_assignments=spool_assignments,
     )
     )
     _active_sessions[printer_id] = session
     _active_sessions[printer_id] = session
 
 
@@ -304,6 +322,7 @@ async def on_print_complete(
             last_progress=data.get("last_progress", 0.0),
             last_progress=data.get("last_progress", 0.0),
             last_layer_num=data.get("last_layer_num", 0),
             last_layer_num=data.get("last_layer_num", 0),
             default_filament_cost=default_filament_cost,
             default_filament_cost=default_filament_cost,
+            spool_assignments=session.spool_assignments if session else None,
         )
         )
         results.extend(threemf_results)
         results.extend(threemf_results)
 
 
@@ -338,20 +357,23 @@ async def on_print_complete(
                     if delta_pct <= 0:
                     if delta_pct <= 0:
                         continue  # No consumption or tray was refilled
                         continue  # No consumption or tray was refilled
 
 
-                    # Look up SpoolAssignment for this slot
-                    result = await db.execute(
-                        select(SpoolAssignment).where(
-                            SpoolAssignment.printer_id == printer_id,
-                            SpoolAssignment.ams_id == ams_id,
-                            SpoolAssignment.tray_id == tray_id,
+                    # Look up spool: prefer snapshot (survives mid-print unlink), fall back to live query
+                    spool_id = session.spool_assignments.get(key) if session.spool_assignments else None
+                    if spool_id is None:
+                        result = await db.execute(
+                            select(SpoolAssignment).where(
+                                SpoolAssignment.printer_id == printer_id,
+                                SpoolAssignment.ams_id == ams_id,
+                                SpoolAssignment.tray_id == tray_id,
+                            )
                         )
                         )
-                    )
-                    assignment = result.scalar_one_or_none()
-                    if not assignment:
-                        continue
+                        assignment = result.scalar_one_or_none()
+                        if not assignment:
+                            continue
+                        spool_id = assignment.spool_id
 
 
                     # Load spool
                     # Load spool
-                    spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
+                    spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
                     spool = spool_result.scalar_one_or_none()
                     spool = spool_result.scalar_one_or_none()
                     if not spool:
                     if not spool:
                         continue
                         continue
@@ -454,6 +476,7 @@ async def _track_from_3mf(
     last_progress: float = 0.0,
     last_progress: float = 0.0,
     last_layer_num: int = 0,
     last_layer_num: int = 0,
     default_filament_cost: float = 0.0,
     default_filament_cost: float = 0.0,
+    spool_assignments: dict[tuple[int, int], int] | None = None,
 ) -> list[dict]:
 ) -> list[dict]:
     """Track usage from 3MF per-filament slicer data (primary path).
     """Track usage from 3MF per-filament slicer data (primary path).
 
 
@@ -662,21 +685,26 @@ async def _track_from_3mf(
         if key in handled_trays:
         if key in handled_trays:
             continue
             continue
 
 
-        # Find spool assignment for this tray
-        assign_result = await db.execute(
-            select(SpoolAssignment).where(
-                SpoolAssignment.printer_id == printer_id,
-                SpoolAssignment.ams_id == ams_id,
-                SpoolAssignment.tray_id == tray_id,
+        # Find spool: prefer snapshot (survives mid-print unlink), fall back to live query
+        spool_id = spool_assignments.get(key) if spool_assignments else None
+        if spool_id is None:
+            assign_result = await db.execute(
+                select(SpoolAssignment).where(
+                    SpoolAssignment.printer_id == printer_id,
+                    SpoolAssignment.ams_id == ams_id,
+                    SpoolAssignment.tray_id == tray_id,
+                )
             )
             )
-        )
-        assignment = assign_result.scalar_one_or_none()
-        if not assignment:
-            logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
-            continue
+            assignment = assign_result.scalar_one_or_none()
+            if not assignment:
+                logger.info(
+                    "[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id
+                )
+                continue
+            spool_id = assignment.spool_id
 
 
         # Load spool
         # Load spool
-        spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
+        spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
         spool = spool_result.scalar_one_or_none()
         spool = spool_result.scalar_one_or_none()
         if not spool:
         if not spool:
             continue
             continue

+ 243 - 0
backend/tests/integration/test_background_dispatch_api.py

@@ -0,0 +1,243 @@
+"""Integration tests for background dispatch API behavior."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.background_dispatch import DispatchEnqueueRejected
+
+
+class TestBackgroundDispatchArchivesAPI:
+    """Tests for archive reprint dispatch endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reprint_returns_dispatched_payload(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
+    ):
+        """Reprint endpoint returns background dispatch metadata."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            filename="widget.gcode.3mf",
+            file_path="archives/test/widget.gcode.3mf",
+        )
+
+        archive_file = tmp_path / archive.file_path
+        archive_file.parent.mkdir(parents=True, exist_ok=True)
+        archive_file.write_bytes(b"3mf-data")
+
+        with (
+            patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
+            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
+            patch(
+                "backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
+                new=AsyncMock(return_value={"dispatch_job_id": 15, "dispatch_position": 1}),
+            ) as mock_dispatch,
+        ):
+            response = await async_client.post(
+                f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
+                json={"plate_id": 2},
+            )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["status"] == "dispatched"
+        assert data["dispatch_job_id"] == 15
+        assert data["dispatch_position"] == 1
+        assert data["filename"] == "widget.gcode.3mf"
+
+        mock_dispatch.assert_awaited_once()
+        kwargs = mock_dispatch.await_args.kwargs
+        assert kwargs["archive_name"].endswith("• Plate 2")
+        assert kwargs["options"]["plate_id"] == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reprint_returns_409_when_enqueue_rejected(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
+    ):
+        """Reprint endpoint maps enqueue rejection to HTTP 409."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            filename="widget2.gcode.3mf",
+            file_path="archives/test/widget2.gcode.3mf",
+        )
+
+        archive_file = tmp_path / archive.file_path
+        archive_file.parent.mkdir(parents=True, exist_ok=True)
+        archive_file.write_bytes(b"3mf-data")
+
+        with (
+            patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
+            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
+            patch(
+                "backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
+                new=AsyncMock(side_effect=DispatchEnqueueRejected("already has a background dispatch")),
+            ),
+        ):
+            response = await async_client.post(
+                f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
+                json={"plate_id": 1},
+            )
+
+        assert response.status_code == 409
+        assert "already has a background dispatch" in response.json()["detail"]
+
+
+class TestBackgroundDispatchLibraryAPI:
+    """Tests for library print dispatch endpoint."""
+
+    @pytest.fixture
+    async def library_file_factory(self, db_session):
+        """Factory to create library files."""
+
+        async def _create_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            defaults = {
+                "filename": "library_part.gcode.3mf",
+                "file_path": "library/files/library_part.gcode.3mf",
+                "file_type": "gcode",
+                "file_size": 1024,
+            }
+            defaults.update(kwargs)
+            lib_file = LibraryFile(**defaults)
+            db_session.add(lib_file)
+            await db_session.commit()
+            await db_session.refresh(lib_file)
+            return lib_file
+
+        return _create_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_print_returns_dispatched_payload(
+        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
+    ):
+        """Library print endpoint returns dispatch job metadata."""
+        printer = await printer_factory()
+        lib_file = await library_file_factory()
+
+        disk_path = tmp_path / lib_file.file_path
+        disk_path.parent.mkdir(parents=True, exist_ok=True)
+        disk_path.write_bytes(b"library data")
+
+        with (
+            patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
+            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
+            patch(
+                "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
+                new=AsyncMock(return_value={"dispatch_job_id": 21, "dispatch_position": 2}),
+            ) as mock_dispatch,
+        ):
+            response = await async_client.post(
+                f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
+                json={"plate_id": 4},
+            )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["status"] == "dispatched"
+        assert data["dispatch_job_id"] == 21
+        assert data["dispatch_position"] == 2
+        assert data["archive_id"] is None
+
+        mock_dispatch.assert_awaited_once()
+        kwargs = mock_dispatch.await_args.kwargs
+        assert kwargs["filename"].endswith("• Plate 4")
+        assert kwargs["options"]["plate_id"] == 4
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_print_returns_409_when_enqueue_rejected(
+        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
+    ):
+        """Library print endpoint maps enqueue rejection to HTTP 409."""
+        printer = await printer_factory()
+        lib_file = await library_file_factory(filename="another_part.gcode")
+
+        disk_path = tmp_path / lib_file.file_path
+        disk_path.parent.mkdir(parents=True, exist_ok=True)
+        disk_path.write_bytes(b"library data")
+
+        with (
+            patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
+            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
+            patch(
+                "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
+                new=AsyncMock(side_effect=DispatchEnqueueRejected("queue conflict")),
+            ),
+        ):
+            response = await async_client.post(
+                f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
+                json={"plate_id": 1},
+            )
+
+        assert response.status_code == 409
+        assert "queue conflict" in response.json()["detail"]
+
+
+class TestBackgroundDispatchCancelAPI:
+    """Tests for /background-dispatch cancel endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_job_returns_cancelled(self, async_client: AsyncClient):
+        """Cancel endpoint returns cancelled for queued job."""
+        with patch(
+            "backend.app.services.background_dispatch.background_dispatch.cancel_job",
+            new=AsyncMock(
+                return_value={
+                    "cancelled": True,
+                    "pending": False,
+                    "job_id": 9,
+                    "source_name": "cube.gcode.3mf",
+                    "printer_id": 1,
+                    "printer_name": "Printer A",
+                }
+            ),
+        ):
+            response = await async_client.delete("/api/v1/background-dispatch/9")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["status"] == "cancelled"
+        assert data["job_id"] == 9
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_job_returns_cancelling_for_active_job(self, async_client: AsyncClient):
+        """Cancel endpoint returns cancelling while active upload is being interrupted."""
+        with patch(
+            "backend.app.services.background_dispatch.background_dispatch.cancel_job",
+            new=AsyncMock(
+                return_value={
+                    "cancelled": True,
+                    "pending": True,
+                    "job_id": 10,
+                    "source_name": "cube.gcode.3mf",
+                    "printer_id": 1,
+                    "printer_name": "Printer A",
+                }
+            ),
+        ):
+            response = await async_client.delete("/api/v1/background-dispatch/10")
+
+        assert response.status_code == 200
+        assert response.json()["status"] == "cancelling"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_job_returns_404_when_not_found(self, async_client: AsyncClient):
+        """Cancel endpoint returns 404 for unknown job id."""
+        with patch(
+            "backend.app.services.background_dispatch.background_dispatch.cancel_job",
+            new=AsyncMock(return_value={"cancelled": False, "reason": "not_found"}),
+        ):
+            response = await async_client.delete("/api/v1/background-dispatch/999")
+
+        assert response.status_code == 404
+        assert response.json()["detail"] == "Dispatch job not found"

+ 322 - 0
backend/tests/unit/services/test_background_dispatch.py

@@ -0,0 +1,322 @@
+"""Unit tests for background dispatch service."""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.background_dispatch import (
+    ActiveDispatchState,
+    BackgroundDispatchService,
+    DispatchEnqueueRejected,
+    PrintDispatchJob,
+)
+
+
+@pytest.mark.asyncio
+async def test_dispatch_rejects_when_printer_busy_printing():
+    """Reject enqueue when target printer is already printing."""
+    service = BackgroundDispatchService()
+
+    with (
+        patch(
+            "backend.app.services.background_dispatch.printer_manager.get_status",
+            return_value=SimpleNamespace(state="RUNNING", gcode_file="active.gcode.3mf"),
+        ),
+        pytest.raises(DispatchEnqueueRejected, match="currently busy printing"),
+    ):
+        await service.dispatch_reprint_archive(
+            archive_id=1,
+            archive_name="Test Archive",
+            printer_id=10,
+            printer_name="Printer A",
+            options={},
+            requested_by_user_id=None,
+            requested_by_username=None,
+        )
+
+
+@pytest.mark.asyncio
+async def test_dispatch_enqueues_job_and_broadcasts_state():
+    """Enqueue succeeds and emits websocket queue update."""
+    service = BackgroundDispatchService()
+
+    with (
+        patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
+        patch(
+            "backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
+        ) as mock_broadcast,
+    ):
+        result = await service.dispatch_print_library_file(
+            file_id=22,
+            filename="cube.gcode.3mf",
+            printer_id=7,
+            printer_name="Printer B",
+            options={"plate_id": 2},
+            requested_by_user_id=5,
+            requested_by_username="tester",
+        )
+
+    assert result["status"] == "dispatched"
+    assert result["dispatch_job_id"] == 1
+    assert result["dispatch_position"] == 1
+    assert len(service._queued_jobs) == 1
+
+    mock_broadcast.assert_awaited_once()
+    payload = mock_broadcast.await_args.args[0]
+    assert payload["type"] == "background_dispatch"
+    assert payload["data"]["recent_event"]["status"] == "dispatched"
+
+
+@pytest.mark.asyncio
+async def test_cancel_queued_job_removes_it_and_broadcasts():
+    """Cancelling queued job removes it immediately."""
+    service = BackgroundDispatchService()
+
+    with (
+        patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
+        patch(
+            "backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
+        ) as mock_broadcast,
+    ):
+        result = await service.dispatch_reprint_archive(
+            archive_id=1,
+            archive_name="benchy.gcode.3mf",
+            printer_id=1,
+            printer_name="Printer 1",
+            options={},
+            requested_by_user_id=None,
+            requested_by_username=None,
+        )
+        mock_broadcast.reset_mock()
+
+        cancel_result = await service.cancel_job(result["dispatch_job_id"])
+
+    assert cancel_result["cancelled"] is True
+    assert cancel_result["pending"] is False
+    assert len(service._queued_jobs) == 0
+    assert service._batch_total == 0
+
+    mock_broadcast.assert_awaited_once()
+    payload = mock_broadcast.await_args.args[0]
+    assert payload["data"]["recent_event"]["status"] == "cancelled"
+
+
+@pytest.mark.asyncio
+async def test_cancel_active_job_marks_pending_and_sets_cancel_flag():
+    """Cancelling active job marks it as pending cancellation."""
+    service = BackgroundDispatchService()
+    job = PrintDispatchJob(
+        id=42,
+        kind="reprint_archive",
+        source_id=100,
+        source_name="gearbox.gcode.3mf",
+        printer_id=3,
+        printer_name="Printer C",
+    )
+    service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Uploading...")
+
+    with patch(
+        "backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
+    ) as mock_broadcast:
+        result = await service.cancel_job(job.id)
+
+    assert result["cancelled"] is True
+    assert result["pending"] is True
+    assert job.id in service._cancel_requested_job_ids
+
+    mock_broadcast.assert_awaited_once()
+    payload = mock_broadcast.await_args.args[0]
+    assert payload["data"]["recent_event"]["status"] == "cancelling"
+
+
+def test_resolve_plate_id_uses_request_value_when_provided(tmp_path):
+    """Explicit plate_id wins over auto-detection."""
+    file_path = tmp_path / "dummy.3mf"
+    file_path.write_text("not-a-zip")
+
+    plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=9)
+    assert plate_id == 9
+
+
+def test_resolve_plate_id_auto_detects_from_3mf(tmp_path):
+    """Auto-detect plate from Metadata/plate_X.gcode entry."""
+    import zipfile
+
+    file_path = tmp_path / "multi.3mf"
+    with zipfile.ZipFile(file_path, "w") as zf:
+        zf.writestr("Metadata/plate_7.gcode", b"G1 X0 Y0")
+
+    plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=None)
+    assert plate_id == 7
+
+
+def test_is_sliced_file_recognizes_supported_extensions():
+    """Only .gcode and .gcode.3mf should be accepted."""
+    assert BackgroundDispatchService._is_sliced_file("part.gcode") is True
+    assert BackgroundDispatchService._is_sliced_file("part.gcode.3mf") is True
+    assert BackgroundDispatchService._is_sliced_file("part.3mf") is False
+
+
+@pytest.mark.asyncio
+async def test_cancel_job_not_found_returns_false():
+    """Cancelling a nonexistent job returns not_found."""
+    service = BackgroundDispatchService()
+
+    with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
+        result = await service.cancel_job(999)
+
+    assert result["cancelled"] is False
+    assert result["reason"] == "not_found"
+
+
+@pytest.mark.asyncio
+async def test_cancel_job_single_lock_covers_both_active_and_queued():
+    """cancel_job checks both active and queued jobs under a single lock acquisition.
+
+    Regression test for TOCTOU race: previously two separate lock acquisitions allowed
+    the dispatcher loop to move a job from queue to active between them, causing cancel
+    to find it in neither place.
+    """
+    service = BackgroundDispatchService()
+
+    # Set up a job in the queue AND an active job for a different printer
+    active_job = PrintDispatchJob(
+        id=1,
+        kind="reprint_archive",
+        source_id=10,
+        source_name="active.3mf",
+        printer_id=1,
+        printer_name="Printer 1",
+    )
+    service._active_jobs[active_job.id] = ActiveDispatchState(job=active_job, message="Uploading...")
+
+    queued_job = PrintDispatchJob(
+        id=2,
+        kind="reprint_archive",
+        source_id=20,
+        source_name="queued.3mf",
+        printer_id=2,
+        printer_name="Printer 2",
+    )
+    service._queued_jobs.append(queued_job)
+    service._batch_total = 2
+
+    with patch(
+        "backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
+    ) as mock_broadcast:
+        # Cancel the queued job — should find it in single lock acquisition
+        result = await service.cancel_job(2)
+
+    assert result["cancelled"] is True
+    assert result["pending"] is False
+    assert len(service._queued_jobs) == 0
+    # Active job should be untouched
+    assert 1 in service._active_jobs
+
+    mock_broadcast.assert_awaited_once()
+    payload = mock_broadcast.await_args.args[0]
+    assert payload["data"]["recent_event"]["status"] == "cancelled"
+
+
+@pytest.mark.asyncio
+async def test_mark_job_finished_resets_batch_when_all_done():
+    """Batch counters reset after last job completes."""
+    service = BackgroundDispatchService()
+    job = PrintDispatchJob(
+        id=1,
+        kind="reprint_archive",
+        source_id=10,
+        source_name="test.3mf",
+        printer_id=1,
+        printer_name="Printer 1",
+    )
+    service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
+    service._batch_total = 1
+
+    with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
+        await service._mark_job_finished(job, failed=False, message="Complete")
+
+    assert service._batch_total == 0
+    assert service._batch_completed == 0
+    assert service._batch_failed == 0
+
+
+@pytest.mark.asyncio
+async def test_mark_job_finished_no_reset_when_jobs_remain():
+    """Batch counters NOT reset when queued jobs remain."""
+    service = BackgroundDispatchService()
+    job = PrintDispatchJob(
+        id=1,
+        kind="reprint_archive",
+        source_id=10,
+        source_name="test.3mf",
+        printer_id=1,
+        printer_name="Printer 1",
+    )
+    remaining_job = PrintDispatchJob(
+        id=2,
+        kind="reprint_archive",
+        source_id=20,
+        source_name="next.3mf",
+        printer_id=2,
+        printer_name="Printer 2",
+    )
+    service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
+    service._queued_jobs.append(remaining_job)
+    service._batch_total = 2
+
+    with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
+        await service._mark_job_finished(job, failed=False, message="Complete")
+
+    # Batch counters should NOT be reset — remaining job still queued
+    assert service._batch_total == 2
+    assert service._batch_completed == 1
+
+
+@pytest.mark.asyncio
+async def test_mark_job_finished_batch_reset_rechecks_under_lock():
+    """Batch reset re-checks condition inside second lock acquisition.
+
+    Regression test for TOCTOU: a new dispatch between the two lock acquisitions
+    could get its counters zeroed if the re-check is missing.
+    """
+    service = BackgroundDispatchService()
+    job = PrintDispatchJob(
+        id=1,
+        kind="reprint_archive",
+        source_id=10,
+        source_name="test.3mf",
+        printer_id=1,
+        printer_name="Printer 1",
+    )
+    service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
+    service._batch_total = 1
+
+    original_broadcast = AsyncMock()
+
+    async def inject_new_job_during_broadcast(msg):
+        """Simulate a new dispatch arriving between the two lock acquisitions."""
+        await original_broadcast(msg)
+        # After broadcast (lock released), inject a new job before reset re-check
+        if not service._queued_jobs:
+            new_job = PrintDispatchJob(
+                id=99,
+                kind="reprint_archive",
+                source_id=99,
+                source_name="injected.3mf",
+                printer_id=5,
+                printer_name="Printer 5",
+            )
+            service._queued_jobs.append(new_job)
+            service._batch_total = 1
+
+    with patch(
+        "backend.app.services.background_dispatch.ws_manager.broadcast",
+        side_effect=inject_new_job_during_broadcast,
+    ):
+        await service._mark_job_finished(job, failed=False, message="Complete")
+
+    # Re-check should prevent reset since a new job appeared
+    assert service._batch_total == 1
+    assert len(service._queued_jobs) == 1

+ 44 - 0
backend/tests/unit/services/test_bambu_ftp.py

@@ -870,3 +870,47 @@ class TestFailureScenarios:
         result2 = client.download_file("/cache/retry.bin")
         result2 = client.download_file("/cache/retry.bin")
         assert result2 == b"data after retry"
         assert result2 == b"data after retry"
         client.disconnect()
         client.disconnect()
+
+    def test_upload_succeeds_despite_voidresp_error(self, ftp_client_factory, ftp_server, tmp_path):
+        """Upload returns True even when voidresp() gets a non-clean response.
+
+        Regression: Previously, a voidresp() error after successful data transfer
+        returned False, which caused with_ftp_retry to re-upload the entire file
+        in a loop.
+        """
+        content = b"voidresp test data"
+        local = tmp_path / "voidresp_test.3mf"
+        local.write_bytes(content)
+        client = ftp_client_factory(printer_model="X1C")
+        client.connect()
+        result = client.upload_file(local, "/cache/voidresp_test.3mf")
+        assert result is True
+        client.disconnect()
+        # Verify the file is actually on the server
+        time.sleep(_UPLOAD_FLUSH_DELAY)
+        client2 = ftp_client_factory()
+        client2.connect()
+        downloaded = client2.download_file("/cache/voidresp_test.3mf")
+        assert downloaded == content
+        client2.disconnect()
+
+    def test_upload_a1_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):
+        """A1 models skip voidresp() entirely and still return True.
+
+        Regression: A1 printers hang on voidresp() after transfercmd uploads.
+        """
+        content = b"A1 upload test"
+        local = tmp_path / "a1_test.3mf"
+        local.write_bytes(content)
+        client = ftp_client_factory(printer_model="A1")
+        client.connect()
+        result = client.upload_file(local, "/cache/a1_test.3mf")
+        assert result is True
+        client.disconnect()
+        # Verify the file is actually on the server
+        time.sleep(_UPLOAD_FLUSH_DELAY)
+        client2 = ftp_client_factory()
+        client2.connect()
+        downloaded = client2.download_file("/cache/a1_test.3mf")
+        assert downloaded == content
+        client2.disconnect()

+ 271 - 0
backend/tests/unit/services/test_usage_tracker.py

@@ -399,3 +399,274 @@ class TestTrackFrom3MF:
         assert len(results) == 1
         assert len(results) == 1
         assert results[0]["ams_id"] == 1
         assert results[0]["ams_id"] == 1
         assert results[0]["tray_id"] == 0
         assert results[0]["tray_id"] == 0
+
+
+class TestSpoolAssignmentSnapshot:
+    """Tests for spool assignment snapshotting at print start (#459).
+
+    When a spool runs empty mid-print, on_ams_change deletes the SpoolAssignment.
+    The snapshot captured at print start ensures usage is still attributed correctly.
+    """
+
+    @pytest.fixture(autouse=True)
+    def _clear_sessions(self):
+        _active_sessions.clear()
+        yield
+        _active_sessions.clear()
+
+    @pytest.mark.asyncio
+    async def test_on_print_start_snapshots_assignments_with_db(self):
+        """on_print_start captures spool assignments when db is provided."""
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 60}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data, tray_now=0))
+
+        assignment_0 = _make_assignment(spool_id=10, printer_id=1, ams_id=0, tray_id=0)
+        assignment_1 = _make_assignment(spool_id=20, printer_id=1, ams_id=0, tray_id=1)
+
+        db = AsyncMock()
+        scalars_mock = MagicMock()
+        scalars_mock.all.return_value = [assignment_0, assignment_1]
+        result_mock = MagicMock()
+        result_mock.scalars.return_value = scalars_mock
+        db.execute = AsyncMock(return_value=result_mock)
+
+        await on_print_start(1, {"subtask_name": "Benchy"}, pm, db=db)
+
+        session = _active_sessions[1]
+        assert session.spool_assignments == {(0, 0): 10, (0, 1): 20}
+
+    @pytest.mark.asyncio
+    async def test_on_print_start_empty_snapshot_without_db(self):
+        """on_print_start creates empty snapshot when no db provided."""
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data, tray_now=0))
+
+        await on_print_start(1, {"subtask_name": "Benchy"}, pm)
+
+        session = _active_sessions[1]
+        assert session.spool_assignments == {}
+
+    @pytest.mark.asyncio
+    async def test_3mf_uses_snapshot_instead_of_live_query(self):
+        """_track_from_3mf uses snapshot spool_id without querying SpoolAssignment."""
+        spool = _make_spool(id=42, label_weight=1000)
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        # db: archive, queue_item(None), spool — NO assignment query needed
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
+        filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="Test",
+                handled_trays=set(),
+                printer_manager=pm,
+                db=db,
+                spool_assignments={(0, 0): 42},
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 42
+        assert results[0]["weight_used"] == 15.0
+
+    @pytest.mark.asyncio
+    async def test_3mf_falls_back_to_live_query_without_snapshot(self):
+        """_track_from_3mf queries SpoolAssignment when no snapshot exists."""
+        spool = _make_spool(id=5, label_weight=1000)
+        assignment = _make_assignment(spool_id=5)
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        # db: archive, queue_item(None), assignment, spool
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="Test",
+                handled_trays=set(),
+                printer_manager=pm,
+                db=db,
+                spool_assignments=None,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 5
+
+    @pytest.mark.asyncio
+    async def test_ams_delta_uses_snapshot_over_live_query(self):
+        """AMS remain% fallback uses snapshot spool_id instead of live query."""
+        spool = _make_spool(id=77, label_weight=1000)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Benchy",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            spool_assignments={(0, 0): 77},
+        )
+
+        # Current remain = 70% → 10% delta → 100g
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data))
+
+        # db only returns spool (NO assignment query)
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        results = await on_print_complete(
+            printer_id=1,
+            data={"status": "completed"},
+            printer_manager=pm,
+            db=db,
+            archive_id=None,
+        )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 77
+        assert results[0]["weight_used"] == 100.0
+
+    @pytest.mark.asyncio
+    async def test_ams_delta_falls_back_to_live_query_without_snapshot(self):
+        """AMS remain% fallback queries SpoolAssignment when snapshot is empty."""
+        spool = _make_spool(id=33, label_weight=1000)
+        assignment = _make_assignment(spool_id=33)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Benchy",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            spool_assignments={},  # Empty snapshot (pre-upgrade session)
+        )
+
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data))
+
+        # db returns assignment then spool
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        results = await on_print_complete(
+            printer_id=1,
+            data={"status": "completed"},
+            printer_manager=pm,
+            db=db,
+            archive_id=None,
+        )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 33
+
+    @pytest.mark.asyncio
+    async def test_snapshot_survives_mid_print_unlink(self):
+        """Core bug scenario: snapshot provides spool_id after mid-print unlink.
+
+        Simulates the #459 scenario: spool runs empty mid-print, on_ams_change
+        deletes the SpoolAssignment, but the snapshot from print start still
+        has the spool_id so usage is correctly attributed at print completion.
+        """
+        spool = _make_spool(id=8, label_weight=1000, weight_used=50)
+        archive = MagicMock()
+        archive.file_path = "archives/big_print.3mf"
+
+        # Session was created at print start WITH snapshot
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Big Print",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 90},
+            spool_assignments={(0, 0): 8},  # Snapshot from print start
+        )
+
+        pm = _make_printer_manager(
+            _make_printer_state(
+                [{"id": 0, "tray": [{"id": 0, "remain": 75}]}],
+                tray_now=0,
+            )
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
+
+        # db: archive, queue_item(None), spool
+        # NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=pm,
+                db=db,
+                archive_id=100,
+            )
+
+        # Usage should be tracked despite assignment being deleted mid-print
+        assert len(results) >= 1
+        assert results[0]["spool_id"] == 8
+        assert results[0]["weight_used"] == 14.2
+        # Spool weight should be updated: 50 + 14.2 = 64.2
+        assert spool.weight_used == 64.2

+ 88 - 0
frontend/src/__tests__/components/PrintModalDispatchToast.test.tsx

@@ -0,0 +1,88 @@
+/**
+ * Test that reprint mode does not show the "Print queued for printer" toast.
+ * The background dispatch websocket toast handles feedback instead.
+ *
+ * Separate file because vi.mock(ToastContext) must be module-scoped
+ * and would interfere with the main PrintModal test suite.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+// Mock the toast context before importing the component
+const mockShowToast = vi.fn();
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return {
+    ...actual,
+    useToast: () => ({ showToast: mockShowToast }),
+  };
+});
+
+import { render } from '../utils';
+import { PrintModal } from '../../components/PrintModal';
+
+const mockPrinters = [
+  { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
+];
+
+describe('PrintModal dispatch toast', () => {
+  const mockOnClose = vi.fn();
+  const mockOnSuccess = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/printers/', () => {
+        return HttpResponse.json(mockPrinters);
+      }),
+      http.get('/api/v1/archives/:id/plates', () => {
+        return HttpResponse.json({ is_multi_plate: false, plates: [] });
+      }),
+      http.get('/api/v1/archives/:id/filament-requirements', () => {
+        return HttpResponse.json({ filaments: [] });
+      }),
+      http.get('/api/v1/printers/:id/status', () => {
+        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
+      }),
+      http.post('/api/v1/archives/:id/reprint', () => {
+        return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 1 });
+      }),
+    );
+  });
+
+  it('does not show "queued" toast in reprint mode (dispatch toast handles it)', async () => {
+    const user = userEvent.setup();
+    render(
+      <PrintModal
+        mode="reprint"
+        archiveId={1}
+        archiveName="Benchy"
+        onClose={mockOnClose}
+        onSuccess={mockOnSuccess}
+      />
+    );
+
+    // Wait for printers to load, then select one
+    await waitFor(() => {
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+    });
+    await user.click(screen.getByText('X1 Carbon'));
+
+    // Submit the print
+    const printButton = screen.getByRole('button', { name: /^print$/i });
+    await user.click(printButton);
+
+    // Wait for the API call to complete and modal to close
+    await waitFor(() => {
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+
+    // showToast should NOT have been called with "Print queued for printer"
+    const toastMessages = mockShowToast.mock.calls.map(call => call[0]);
+    expect(toastMessages).not.toContain('Print queued for printer');
+  });
+});

+ 23 - 2
frontend/src/api/client.ts

@@ -1630,6 +1630,15 @@ export interface NotificationTestResponse {
   message: string;
   message: string;
 }
 }
 
 
+export interface BackgroundDispatchResponse {
+  status: 'dispatched' | string;
+  printer_id: number;
+  archive_id?: number | null;
+  filename: string;
+  dispatch_job_id: number;
+  dispatch_position: number;
+}
+
 // Provider-specific config types for reference
 // Provider-specific config types for reference
 export interface CallMeBotConfig {
 export interface CallMeBotConfig {
   phone: string;
   phone: string;
@@ -2939,6 +2948,7 @@ export const api = {
     printerId: number,
     printerId: number,
     options?: {
     options?: {
       plate_id?: number;
       plate_id?: number;
+      plate_name?: string;
       ams_mapping?: number[];
       ams_mapping?: number[];
       timelapse?: boolean;
       timelapse?: boolean;
       bed_levelling?: boolean;
       bed_levelling?: boolean;
@@ -2948,7 +2958,7 @@ export const api = {
       use_ams?: boolean;
       use_ams?: boolean;
     }
     }
   ) =>
   ) =>
-    request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
+    request<BackgroundDispatchResponse>(
       `/archives/${archiveId}/reprint?printer_id=${printerId}`,
       `/archives/${archiveId}/reprint?printer_id=${printerId}`,
       {
       {
         method: 'POST',
         method: 'POST',
@@ -4010,6 +4020,7 @@ export const api = {
     printerId: number,
     printerId: number,
     options?: {
     options?: {
       plate_id?: number;
       plate_id?: number;
+      plate_name?: string;
       ams_mapping?: number[];
       ams_mapping?: number[];
       bed_levelling?: boolean;
       bed_levelling?: boolean;
       flow_cali?: boolean;
       flow_cali?: boolean;
@@ -4019,13 +4030,23 @@ export const api = {
       use_ams?: boolean;
       use_ams?: boolean;
     }
     }
   ) =>
   ) =>
-    request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
+    request<BackgroundDispatchResponse>(
       `/library/files/${fileId}/print?printer_id=${printerId}`,
       `/library/files/${fileId}/print?printer_id=${printerId}`,
       {
       {
         method: 'POST',
         method: 'POST',
         body: options ? JSON.stringify(options) : undefined,
         body: options ? JSON.stringify(options) : undefined,
       }
       }
     ),
     ),
+  cancelBackgroundDispatchJob: (jobId: number) =>
+    request<{
+      status: 'cancelled' | 'cancelling';
+      job_id: number;
+      source_name: string;
+      printer_id: number;
+      printer_name: string;
+    }>(`/background-dispatch/${jobId}`, {
+      method: 'DELETE',
+    }),
   getLibraryFilePlates: (fileId: number) =>
   getLibraryFilePlates: (fileId: number) =>
     request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
     request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
   getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
   getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>

+ 30 - 18
frontend/src/components/PrintModal/index.tsx

@@ -1,28 +1,28 @@
-import { useState, useEffect, useMemo } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { AlertCircle, AlertTriangle, Calendar, Loader2, Pencil, Printer, X } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
-import { api } from '../../api/client';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
-import { Card, CardContent } from '../Card';
-import { Button } from '../Button';
+import { api } from '../../api/client';
 import { useToast } from '../../contexts/ToastContext';
 import { useToast } from '../../contexts/ToastContext';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { isPlaceholderDate } from '../../utils/amsHelpers';
 import { isPlaceholderDate } from '../../utils/amsHelpers';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getCurrencySymbol } from '../../utils/currency';
 import { toDateTimeLocalValue } from '../../utils/date';
 import { toDateTimeLocalValue } from '../../utils/date';
-import { PrinterSelector } from './PrinterSelector';
-import { PlateSelector } from './PlateSelector';
+import { Button } from '../Button';
+import { Card, CardContent } from '../Card';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentMapping } from './FilamentMapping';
+import { PlateSelector } from './PlateSelector';
+import { PrinterSelector } from './PrinterSelector';
 import { PrintOptionsPanel } from './PrintOptions';
 import { PrintOptionsPanel } from './PrintOptions';
 import { ScheduleOptionsPanel } from './ScheduleOptions';
 import { ScheduleOptionsPanel } from './ScheduleOptions';
 import type {
 import type {
+  AssignmentMode,
   PrintModalProps,
   PrintModalProps,
   PrintOptions,
   PrintOptions,
   ScheduleOptions,
   ScheduleOptions,
   ScheduleType,
   ScheduleType,
-  AssignmentMode,
 } from './types';
 } from './types';
 import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
 import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
 
 
@@ -234,6 +234,12 @@ export function PrintModal({
 
 
   // Combine filament requirements from either source
   // Combine filament requirements from either source
   const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
   const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
+  const selectedPlateName = useMemo(() => {
+    if (selectedPlate === null || !platesData?.plates?.length) {
+      return undefined;
+    }
+    return platesData.plates.find((plate) => plate.index === selectedPlate)?.name || undefined;
+  }, [platesData, selectedPlate]);
 
 
   // Only fetch printer status when single printer selected (for filament mapping)
   // Only fetch printer status when single printer selected (for filament mapping)
   const { data: printerStatus } = useQuery({
   const { data: printerStatus } = useQuery({
@@ -454,12 +460,15 @@ export function PrintModal({
             const printerMapping = getMappingForPrinter(printerId);
             const printerMapping = getMappingForPrinter(printerId);
             if (isLibraryFile) {
             if (isLibraryFile) {
               await api.printLibraryFile(libraryFileId!, printerId, {
               await api.printLibraryFile(libraryFileId!, printerId, {
+                plate_id: selectedPlate ?? undefined,
+                plate_name: selectedPlateName,
                 ams_mapping: printerMapping,
                 ams_mapping: printerMapping,
                 ...printOptions,
                 ...printOptions,
               });
               });
             } else {
             } else {
               await api.reprintArchive(archiveId!, printerId, {
               await api.reprintArchive(archiveId!, printerId, {
                 plate_id: selectedPlate ?? undefined,
                 plate_id: selectedPlate ?? undefined,
+                plate_name: selectedPlateName,
                 ams_mapping: printerMapping,
                 ams_mapping: printerMapping,
                 ...printOptions,
                 ...printOptions,
               });
               });
@@ -497,16 +506,19 @@ export function PrintModal({
 
 
     setIsSubmitting(false);
     setIsSubmitting(false);
 
 
-    // Show result toast
+    // Show result toast (skip for reprint mode — the dispatch toast handles it)
     if (results.failed === 0) {
     if (results.failed === 0) {
-      if (assignmentMode === 'model') {
-        showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
-      } else {
-        const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
-        if (results.success === 1) {
-          showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
+      if (mode !== 'reprint') {
+        if (assignmentMode === 'model') {
+          showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
         } else {
         } else {
-          showToast(`Print ${action} ${results.success} printers`);
+          if (mode === 'edit-queue-item') {
+            showToast('Queue item updated');
+          } else if (results.success === 1) {
+            showToast('Print queued for printer');
+          } else {
+            showToast(`Print queued for ${results.success} printers`);
+          }
         }
         }
       }
       }
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       queryClient.invalidateQueries({ queryKey: ['queue'] });
@@ -749,4 +761,4 @@ export function PrintModal({
 }
 }
 
 
 // Re-export types for convenience
 // Re-export types for convenience
-export type { PrintModalProps, PrintModalMode } from './types';
+export type { PrintModalMode, PrintModalProps } from './types';

+ 440 - 11
frontend/src/contexts/ToastContext.tsx

@@ -1,5 +1,7 @@
-import { createContext, useContext, useState, useCallback, useRef, useEffect, type ReactNode } from 'react';
-import { CheckCircle, XCircle, AlertCircle, Info, X, Loader2 } from 'lucide-react';
+import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCircle } from 'lucide-react';
+import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { api } from '../api/client';
 
 
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
 
 
@@ -8,6 +10,29 @@ interface Toast {
   message: string;
   message: string;
   type: ToastType;
   type: ToastType;
   persistent?: boolean;
   persistent?: boolean;
+  dispatchData?: DispatchToastData;
+}
+
+type DispatchJobStatus = 'dispatched' | 'processing' | 'completed' | 'failed' | 'cancelled';
+
+interface DispatchToastJob {
+  jobId: number;
+  sourceName: string;
+  printerName: string;
+  status: DispatchJobStatus;
+  message?: string;
+  uploadBytes?: number;
+  uploadTotalBytes?: number;
+  uploadProgressPct?: number;
+}
+
+interface DispatchToastData {
+  total: number;
+  dispatched: number;
+  processing: number;
+  completed: number;
+  failed: number;
+  jobs: DispatchToastJob[];
 }
 }
 
 
 interface ToastContextType {
 interface ToastContextType {
@@ -43,8 +68,21 @@ const bgColors = {
 };
 };
 
 
 export function ToastProvider({ children }: { children: ReactNode }) {
 export function ToastProvider({ children }: { children: ReactNode }) {
+  const { t } = useTranslation();
   const [toasts, setToasts] = useState<Toast[]>([]);
   const [toasts, setToasts] = useState<Toast[]>([]);
+  const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
+  const [cancellingDispatchJobIds, setCancellingDispatchJobIds] = useState<Set<number>>(new Set());
   const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
   const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
+  const dispatchToastId = 'background-dispatch';
+  const lastDispatchSummaryRef = useRef<string | null>(null);
+
+  const formatBytes = useCallback((bytes: number) => {
+    if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
+    if (bytes < 1024) return `${bytes} B`;
+    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+    if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+    return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
+  }, []);
 
 
   // Clean up all timeouts on unmount
   // Clean up all timeouts on unmount
   useEffect(() => {
   useEffect(() => {
@@ -88,6 +126,279 @@ export function ToastProvider({ children }: { children: ReactNode }) {
     setToasts((prev) => prev.filter((t) => t.id !== id));
     setToasts((prev) => prev.filter((t) => t.id !== id));
   }, []);
   }, []);
 
 
+  const cancelDispatchJob = useCallback(async (jobId: number) => {
+    setCancellingDispatchJobIds((prev) => {
+      const next = new Set(prev);
+      next.add(jobId);
+      return next;
+    });
+
+    try {
+      const result = await api.cancelBackgroundDispatchJob(jobId);
+      showToast(
+        result.status === 'cancelling'
+          ? t('backgroundDispatch.toast.cancellingUpload')
+          : t('backgroundDispatch.toast.cancelled'),
+        'info'
+      );
+    } catch (error) {
+      const message = error instanceof Error ? error.message : t('backgroundDispatch.toast.cancelFailed');
+      showToast(message, 'error');
+    } finally {
+      setCancellingDispatchJobIds((prev) => {
+        const next = new Set(prev);
+        next.delete(jobId);
+        return next;
+      });
+    }
+  }, [showToast, t]);
+
+  useEffect(() => {
+    interface DispatchEventDetail {
+      total?: number;
+      dispatched?: number;
+      processing?: number;
+      completed?: number;
+      failed?: number;
+      dispatched_jobs?: Array<{
+        job_id: number;
+        source_name?: string;
+        printer_name?: string;
+      }>;
+      active_job?: {
+        job_id?: number;
+        printer_name?: string;
+        source_name?: string;
+        message?: string;
+        upload_bytes?: number;
+        upload_total_bytes?: number;
+        upload_progress_pct?: number;
+      } | null;
+      active_jobs?: Array<{
+        job_id?: number;
+        printer_name?: string;
+        source_name?: string;
+        message?: string;
+        upload_bytes?: number;
+        upload_total_bytes?: number;
+        upload_progress_pct?: number;
+      }>;
+      recent_event?: {
+        status?: string;
+        job_id?: number;
+        source_name?: string;
+        printer_name?: string;
+        message?: string;
+      };
+    }
+
+    const updateJob = (
+      jobs: DispatchToastJob[],
+      jobId: number,
+      next: Partial<DispatchToastJob> & {
+        status: DispatchJobStatus;
+        sourceName: string;
+        printerName: string;
+      }
+    ) => {
+      const index = jobs.findIndex((job) => job.jobId === jobId);
+      if (index === -1) {
+        return [...jobs, { jobId, ...next }];
+      }
+      const copy = [...jobs];
+      copy[index] = {
+        ...copy[index],
+        ...next,
+      };
+      return copy;
+    };
+
+    const statusWeight = (status: DispatchJobStatus) => {
+      switch (status) {
+        case 'failed':
+          return 0;
+        case 'processing':
+          return 1;
+        case 'dispatched':
+          return 2;
+        case 'completed':
+          return 3;
+        case 'cancelled':
+          return 4;
+      }
+    };
+
+    const onDispatchEvent = (event: Event) => {
+      const detail = (event as CustomEvent<DispatchEventDetail>).detail || {};
+      const total = detail.total ?? 0;
+      const dispatched = detail.dispatched ?? 0;
+      const processing = detail.processing ?? 0;
+      const completed = detail.completed ?? 0;
+      const failed = detail.failed ?? 0;
+
+      const hasActiveWork = dispatched + processing > 0;
+      const allDone = total > 0 && completed + failed >= total && !hasActiveWork;
+
+      if (hasActiveWork) {
+        setToasts((prev) => {
+          const existing = prev.find((toastItem) => toastItem.id === dispatchToastId);
+          const existingJobs = existing?.dispatchData?.jobs || [];
+
+          const dispatchedJobs: DispatchToastJob[] = (detail.dispatched_jobs || []).map((job) => ({
+            jobId: job.job_id,
+            sourceName: job.source_name || t('backgroundDispatch.unknownFile'),
+            printerName: job.printer_name || t('backgroundDispatch.unknownPrinter'),
+            status: 'dispatched',
+          }));
+
+          const activeJobsPayload =
+            detail.active_jobs && detail.active_jobs.length > 0
+              ? detail.active_jobs
+              : detail.active_job?.job_id
+                ? [detail.active_job]
+                : [];
+
+          const activeJobs: DispatchToastJob[] = activeJobsPayload
+            .filter((job) => typeof job.job_id === 'number')
+            .map((job) => ({
+              jobId: job.job_id as number,
+              sourceName: job.source_name || t('backgroundDispatch.unknownFile'),
+              printerName: job.printer_name || t('backgroundDispatch.unknownPrinter'),
+              status: 'processing',
+              message: job.message,
+              uploadBytes: job.upload_bytes,
+              uploadTotalBytes: job.upload_total_bytes,
+              uploadProgressPct: job.upload_progress_pct,
+            }));
+
+          const activeIds = new Set([...dispatchedJobs, ...activeJobs].map((job) => job.jobId));
+          const historicalJobs = existingJobs.filter(
+            (job) => !activeIds.has(job.jobId) && ['completed', 'failed', 'cancelled'].includes(job.status)
+          );
+
+          let jobs = [...dispatchedJobs, ...activeJobs, ...historicalJobs];
+
+          if (detail.recent_event?.job_id && detail.recent_event?.status) {
+            const rawStatus = detail.recent_event.status;
+            const eventStatus = (
+              rawStatus === 'cancelled' ? 'cancelled' : rawStatus === 'cancelling' ? 'processing' : rawStatus
+            ) as DispatchJobStatus;
+            const sourceName = detail.recent_event.source_name || t('backgroundDispatch.unknownFile');
+            const printerName = detail.recent_event.printer_name || t('backgroundDispatch.unknownPrinter');
+            jobs = updateJob(jobs, detail.recent_event.job_id, {
+              status: eventStatus,
+              sourceName,
+              printerName,
+              message: detail.recent_event.message,
+            });
+          }
+
+          activeJobs.forEach((activeJob) => {
+            jobs = updateJob(jobs, activeJob.jobId, {
+              status: 'processing',
+              sourceName: activeJob.sourceName,
+              printerName: activeJob.printerName,
+              message: activeJob.message,
+              uploadBytes: activeJob.uploadBytes,
+              uploadTotalBytes: activeJob.uploadTotalBytes,
+              uploadProgressPct: activeJob.uploadProgressPct,
+            });
+          });
+
+          const dispatchData: DispatchToastData = {
+            total,
+            dispatched,
+            processing,
+            completed,
+            failed,
+            jobs: [...jobs].sort((a, b) => {
+              const byStatus = statusWeight(a.status) - statusWeight(b.status);
+              if (byStatus !== 0) {
+                return byStatus;
+              }
+              return a.jobId - b.jobId;
+            }),
+          };
+
+          const exists = prev.find((toastItem) => toastItem.id === dispatchToastId);
+          if (exists) {
+            return prev.map((toastItem) =>
+              toastItem.id === dispatchToastId
+                ? {
+                    ...toastItem,
+                    message: t('backgroundDispatch.startingPrints'),
+                    type: 'loading',
+                    persistent: true,
+                    dispatchData,
+                  }
+                : toastItem
+            );
+          }
+          return [
+            ...prev,
+            {
+              id: dispatchToastId,
+              message: t('backgroundDispatch.startingPrints'),
+              type: 'loading',
+              persistent: true,
+              dispatchData,
+            },
+          ];
+        });
+        return;
+      }
+
+      const recentStatus = detail.recent_event?.status;
+      if (!hasActiveWork && recentStatus && ['cancelled', 'failed', 'completed', 'idle'].includes(recentStatus)) {
+        setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
+      }
+
+      if (allDone) {
+        const summaryKey = `${completed}:${failed}`;
+        if (lastDispatchSummaryRef.current === summaryKey) {
+          return;
+        }
+        lastDispatchSummaryRef.current = summaryKey;
+
+        setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
+        const doneMessage = failed > 0
+          ? t('backgroundDispatch.toast.completeWithFailures', { completed, failed })
+          : t('backgroundDispatch.toast.completeSuccess', { completed });
+        const id = Math.random().toString(36).substr(2, 9);
+        setToasts((prev) => [...prev, { id, message: doneMessage, type: failed > 0 ? 'warning' : 'success' }]);
+        const timeout = setTimeout(() => {
+          setToasts((prev) => prev.filter((t) => t.id !== id));
+          timeoutRefs.current.delete(id);
+        }, 4000);
+        timeoutRefs.current.set(id, timeout);
+      }
+
+      if (detail.recent_event?.status === 'idle' && !hasActiveWork) {
+        setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
+      }
+
+      if (!hasActiveWork) {
+        setCancellingDispatchJobIds(new Set());
+      }
+
+      if (detail.dispatched_jobs) {
+        const dispatchedIds = new Set(detail.dispatched_jobs.map((job) => job.job_id));
+        setCancellingDispatchJobIds((prev) => {
+          const next = new Set<number>();
+          prev.forEach((id) => {
+            if (dispatchedIds.has(id)) {
+              next.add(id);
+            }
+          });
+          return next;
+        });
+      }
+    };
+
+    window.addEventListener('background-dispatch', onDispatchEvent);
+    return () => window.removeEventListener('background-dispatch', onDispatchEvent);
+  }, [t]);
+
   return (
   return (
     <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>
     <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>
       {children}
       {children}
@@ -97,16 +408,134 @@ export function ToastProvider({ children }: { children: ReactNode }) {
         {toasts.map((toast) => (
         {toasts.map((toast) => (
           <div
           <div
             key={toast.id}
             key={toast.id}
-            className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]}`}
+            className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
+              toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
+            }`}
           >
           >
-            {icons[toast.type]}
-            <span className="text-white text-sm">{toast.message}</span>
-            <button
-              onClick={() => dismissToast(toast.id)}
-              className="ml-2 text-bambu-gray hover:text-white transition-colors"
-            >
-              <X className="w-4 h-4" />
-            </button>
+            {toast.dispatchData ? (
+              <>
+                <div className="flex items-start justify-between gap-3">
+                  <div className="flex items-start gap-2">
+                    {icons[toast.type]}
+                    <div>
+                      <p className="text-white text-sm font-medium">{t('backgroundDispatch.startingPrints')}</p>
+                      <p className="text-xs text-bambu-gray mt-0.5">
+                        {t('backgroundDispatch.progressSummary', {
+                          complete: toast.dispatchData.completed + toast.dispatchData.failed,
+                          total: toast.dispatchData.total,
+                          dispatched: toast.dispatchData.dispatched,
+                          processing: toast.dispatchData.processing,
+                        })}
+                      </p>
+                    </div>
+                  </div>
+                  <div className="flex items-center gap-1">
+                    <button
+                      onClick={() => setIsDispatchCollapsed((prev) => !prev)}
+                      className="text-bambu-gray hover:text-white transition-colors"
+                      aria-label={
+                        isDispatchCollapsed
+                          ? t('backgroundDispatch.expandDetails')
+                          : t('backgroundDispatch.collapseDetails')
+                      }
+                    >
+                      {isDispatchCollapsed ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
+                    </button>
+                    <button
+                      onClick={() => dismissToast(toast.id)}
+                      className="text-bambu-gray hover:text-white transition-colors"
+                      aria-label={t('backgroundDispatch.dismissToast')}
+                    >
+                      <X className="w-4 h-4" />
+                    </button>
+                  </div>
+                </div>
+
+                {!isDispatchCollapsed && (
+                  <div className="mt-3 space-y-2 max-h-64 overflow-y-auto pr-1">
+                    {toast.dispatchData.jobs.map((job) => {
+                      const progressByStatus: Record<DispatchJobStatus, number> = {
+                        dispatched: 15,
+                        processing: 60,
+                        completed: 100,
+                        failed: 100,
+                        cancelled: 100,
+                      };
+                      const barColorByStatus: Record<DispatchJobStatus, string> = {
+                        dispatched: 'bg-bambu-gray/60',
+                        processing: 'bg-bambu-green',
+                        completed: 'bg-green-500',
+                        failed: 'bg-red-500',
+                        cancelled: 'bg-yellow-500',
+                      };
+                      return (
+                        <div key={job.jobId} className="rounded border border-white/10 bg-black/15 p-2">
+                          <div className="flex items-center justify-between gap-2">
+                            <span className="text-xs text-white truncate" title={job.sourceName}>
+                              {job.sourceName}
+                            </span>
+                            <div className="flex items-center gap-2">
+                              {(job.status === 'dispatched' || job.status === 'processing') && (
+                                <button
+                                  onClick={() => void cancelDispatchJob(job.jobId)}
+                                  disabled={cancellingDispatchJobIds.has(job.jobId)}
+                                  className="text-[11px] text-red-300 hover:text-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
+                                  title={t('backgroundDispatch.cancelDispatchJob')}
+                                >
+                                  {cancellingDispatchJobIds.has(job.jobId)
+                                    ? t('backgroundDispatch.cancelling')
+                                    : t('backgroundDispatch.cancel')}
+                                </button>
+                              )}
+                              <span className="text-[11px] uppercase tracking-wide text-bambu-gray">
+                                {t(`backgroundDispatch.status.${job.status}`)}
+                              </span>
+                            </div>
+                          </div>
+                          <div className="text-[11px] text-bambu-gray truncate" title={job.printerName}>
+                            {job.printerName}
+                          </div>
+                          {job.message && (
+                            <div className="text-[11px] text-bambu-gray truncate" title={job.message}>
+                              {job.message}
+                            </div>
+                          )}
+                          {job.status === 'processing' && typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 && (
+                            <div className="text-[11px] text-bambu-gray truncate">
+                              {formatBytes(job.uploadBytes)} / {formatBytes(job.uploadTotalBytes)}
+                              {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
+                            </div>
+                          )}
+                          <div className="mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden">
+                            <div
+                              className={`h-full ${barColorByStatus[job.status]} transition-all duration-300`}
+                              style={{
+                                width: `${
+                                  job.status === 'processing' && typeof job.uploadProgressPct === 'number'
+                                    ? Math.max(0, Math.min(100, job.uploadProgressPct))
+                                    : progressByStatus[job.status]
+                                }%`,
+                              }}
+                            />
+                          </div>
+                        </div>
+                      );
+                    })}
+                  </div>
+                )}
+              </>
+            ) : (
+              <>
+                {icons[toast.type]}
+                <span className="text-white text-sm">{toast.message}</span>
+                <button
+                  onClick={() => dismissToast(toast.id)}
+                  className="ml-2 text-bambu-gray hover:text-white transition-colors"
+                >
+                  <X className="w-4 h-4" />
+                </button>
+              </>
+            )}
           </div>
           </div>
         ))}
         ))}
       </div>
       </div>

+ 9 - 1
frontend/src/hooks/useWebSocket.ts

@@ -1,5 +1,5 @@
-import { useEffect, useRef, useCallback, useState } from 'react';
 import { useQueryClient } from '@tanstack/react-query';
 import { useQueryClient } from '@tanstack/react-query';
+import { useCallback, useEffect, useRef, useState } from 'react';
 
 
 interface WebSocketMessage {
 interface WebSocketMessage {
   type: string;
   type: string;
@@ -250,6 +250,14 @@ export function useWebSocket() {
           }
           }
         }));
         }));
         break;
         break;
+
+      case 'background_dispatch':
+        window.dispatchEvent(
+          new CustomEvent('background-dispatch', {
+            detail: (message as unknown as { data?: Record<string, unknown> }).data || {},
+          })
+        );
+        break;
     }
     }
   }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
   }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
 
 

+ 27 - 0
frontend/src/i18n/locales/de.ts

@@ -890,6 +890,33 @@ export default {
     },
     },
   },
   },
 
 
+  backgroundDispatch: {
+    unknownFile: 'Unbekannte Datei',
+    unknownPrinter: 'Unbekannter Drucker',
+    startingPrints: 'Starte Drucke',
+    progressSummary: '{{complete}}/{{total}} abgeschlossen • Geplant: {{dispatched}} • In Bearbeitung: {{processing}}',
+    expandDetails: 'Dispatch-Details ausklappen',
+    collapseDetails: 'Dispatch-Details einklappen',
+    dismissToast: 'Dispatch-Hinweis schließen',
+    cancelDispatchJob: 'Dispatch-Job abbrechen',
+    cancel: 'Abbrechen',
+    cancelling: 'Wird abgebrochen…',
+    status: {
+      dispatched: 'Geplant',
+      processing: 'In Bearbeitung',
+      completed: 'Abgeschlossen',
+      failed: 'Fehlgeschlagen',
+      cancelled: 'Abgebrochen',
+    },
+    toast: {
+      cancellingUpload: 'Upload wird abgebrochen...',
+      cancelled: 'Dispatch abgebrochen',
+      cancelFailed: 'Dispatch konnte nicht abgebrochen werden',
+      completeWithFailures: 'Background Dispatch abgeschlossen: {{completed}} erfolgreich, {{failed}} fehlgeschlagen',
+      completeSuccess: 'Background Dispatch abgeschlossen: {{completed}} erfolgreich',
+    },
+  },
+
   // Statistics page
   // Statistics page
   stats: {
   stats: {
     title: 'Dashboard',
     title: 'Dashboard',

+ 27 - 0
frontend/src/i18n/locales/en.ts

@@ -890,6 +890,33 @@ export default {
     },
     },
   },
   },
 
 
+  backgroundDispatch: {
+    unknownFile: 'Unknown file',
+    unknownPrinter: 'Unknown printer',
+    startingPrints: 'Starting prints',
+    progressSummary: '{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}',
+    expandDetails: 'Expand dispatch details',
+    collapseDetails: 'Collapse dispatch details',
+    dismissToast: 'Dismiss dispatch toast',
+    cancelDispatchJob: 'Cancel dispatch job',
+    cancel: 'Cancel',
+    cancelling: 'Cancelling…',
+    status: {
+      dispatched: 'Dispatched',
+      processing: 'Processing',
+      completed: 'Completed',
+      failed: 'Failed',
+      cancelled: 'Cancelled',
+    },
+    toast: {
+      cancellingUpload: 'Cancelling upload...',
+      cancelled: 'Dispatch cancelled',
+      cancelFailed: 'Failed to cancel dispatch',
+      completeWithFailures: 'Background dispatch complete: {{completed}} succeeded, {{failed}} failed',
+      completeSuccess: 'Background dispatch complete: {{completed}} succeeded',
+    },
+  },
+
   // Statistics page
   // Statistics page
   stats: {
   stats: {
     title: 'Dashboard',
     title: 'Dashboard',

+ 27 - 0
frontend/src/i18n/locales/fr.ts

@@ -882,6 +882,33 @@ export default {
     },
     },
   },
   },
 
 
+  backgroundDispatch: {
+    unknownFile: 'Unknown file',
+    unknownPrinter: 'Unknown printer',
+    startingPrints: 'Starting prints',
+    progressSummary: '{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}',
+    expandDetails: 'Expand dispatch details',
+    collapseDetails: 'Collapse dispatch details',
+    dismissToast: 'Dismiss dispatch toast',
+    cancelDispatchJob: 'Cancel dispatch job',
+    cancel: 'Cancel',
+    cancelling: 'Cancelling…',
+    status: {
+      dispatched: 'Dispatched',
+      processing: 'Processing',
+      completed: 'Completed',
+      failed: 'Failed',
+      cancelled: 'Cancelled',
+    },
+    toast: {
+      cancellingUpload: 'Cancelling upload...',
+      cancelled: 'Dispatch cancelled',
+      cancelFailed: 'Failed to cancel dispatch',
+      completeWithFailures: 'Background dispatch complete: {{completed}} succeeded, {{failed}} failed',
+      completeSuccess: 'Background dispatch complete: {{completed}} succeeded',
+    },
+  },
+
   // Statistics page
   // Statistics page
   stats: {
   stats: {
     title: 'Tableau de bord',
     title: 'Tableau de bord',

+ 27 - 0
frontend/src/i18n/locales/it.ts

@@ -869,6 +869,33 @@ export default {
     },
     },
   },
   },
 
 
+  backgroundDispatch: {
+    unknownFile: 'File sconosciuto',
+    unknownPrinter: 'Stampante sconosciuta',
+    startingPrints: 'Avvio stampe',
+    progressSummary: '{{complete}}/{{total}} completati • Inviati: {{dispatched}} • In elaborazione: {{processing}}',
+    expandDetails: 'Espandi dettagli dispatch',
+    collapseDetails: 'Comprimi dettagli dispatch',
+    dismissToast: 'Chiudi notifica dispatch',
+    cancelDispatchJob: 'Annulla job dispatch',
+    cancel: 'Annulla',
+    cancelling: 'Annullamento…',
+    status: {
+      dispatched: 'Inviato',
+      processing: 'In elaborazione',
+      completed: 'Completato',
+      failed: 'Fallito',
+      cancelled: 'Annullato',
+    },
+    toast: {
+      cancellingUpload: 'Annullamento upload...',
+      cancelled: 'Dispatch annullato',
+      cancelFailed: 'Impossibile annullare il dispatch',
+      completeWithFailures: 'Dispatch in background completato: {{completed}} riusciti, {{failed}} falliti',
+      completeSuccess: 'Dispatch in background completato: {{completed}} riusciti',
+    },
+  },
+
   // Statistics page
   // Statistics page
   stats: {
   stats: {
     title: 'Dashboard',
     title: 'Dashboard',

+ 26 - 0
frontend/src/i18n/locales/ja.ts

@@ -957,6 +957,32 @@ export default {
       noCancelItems: 'キューアイテムをキャンセルする権限がありません',
       noCancelItems: 'キューアイテムをキャンセルする権限がありません',
     },
     },
   },
   },
+  backgroundDispatch: {
+    unknownFile: '不明なファイル',
+    unknownPrinter: '不明なプリンター',
+    startingPrints: '印刷開始中',
+    progressSummary: '{{complete}}/{{total}} 完了 • 配信済み: {{dispatched}} • 処理中: {{processing}}',
+    expandDetails: '配信詳細を展開',
+    collapseDetails: '配信詳細を折りたたむ',
+    dismissToast: '配信トーストを閉じる',
+    cancelDispatchJob: '配信ジョブをキャンセル',
+    cancel: 'キャンセル',
+    cancelling: 'キャンセル中…',
+    status: {
+      dispatched: '配信済み',
+      processing: '処理中',
+      completed: '完了',
+      failed: '失敗',
+      cancelled: 'キャンセル済み',
+    },
+    toast: {
+      cancellingUpload: 'アップロードをキャンセル中...',
+      cancelled: '配信をキャンセルしました',
+      cancelFailed: '配信のキャンセルに失敗しました',
+      completeWithFailures: 'バックグラウンド配信完了: {{completed}} 件成功、{{failed}} 件失敗',
+      completeSuccess: 'バックグラウンド配信完了: {{completed}} 件成功',
+    },
+  },
   stats: {
   stats: {
     title: '統計',
     title: '統計',
     overview: '概要',
     overview: '概要',

+ 27 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -890,6 +890,33 @@ export default {
     },
     },
   },
   },
 
 
+  backgroundDispatch: {
+    unknownFile: 'Unknown file',
+    unknownPrinter: 'Unknown printer',
+    startingPrints: 'Starting prints',
+    progressSummary: '{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}',
+    expandDetails: 'Expand dispatch details',
+    collapseDetails: 'Collapse dispatch details',
+    dismissToast: 'Dismiss dispatch toast',
+    cancelDispatchJob: 'Cancel dispatch job',
+    cancel: 'Cancel',
+    cancelling: 'Cancelling…',
+    status: {
+      dispatched: 'Dispatched',
+      processing: 'Processing',
+      completed: 'Completed',
+      failed: 'Failed',
+      cancelled: 'Cancelled',
+    },
+    toast: {
+      cancellingUpload: 'Cancelling upload...',
+      cancelled: 'Dispatch cancelled',
+      cancelFailed: 'Failed to cancel dispatch',
+      completeWithFailures: 'Background dispatch complete: {{completed}} succeeded, {{failed}} failed',
+      completeSuccess: 'Background dispatch complete: {{completed}} succeeded',
+    },
+  },
+
   // Statistics page
   // Statistics page
   stats: {
   stats: {
     title: 'Dashboard',
     title: 'Dashboard',

+ 28 - 15
frontend/src/pages/InventoryPage.tsx

@@ -16,6 +16,8 @@ import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfig
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { resolveSpoolColorName } from '../utils/colors';
 import { resolveSpoolColorName } from '../utils/colors';
 import { getCurrencySymbol } from '../utils/currency';
 import { getCurrencySymbol } from '../utils/currency';
+import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
+import { formatSlotLabel } from '../utils/amsHelpers';
 
 
 type ArchiveFilter = 'active' | 'archived';
 type ArchiveFilter = 'active' | 'archived';
 type UsageFilter = 'all' | 'used' | 'new';
 type UsageFilter = 'all' | 'used' | 'new';
@@ -100,10 +102,11 @@ const MATERIAL_COLORS: Record<string, string> = {
 
 
 type TFn = (key: string) => string;
 type TFn = (key: string) => string;
 
 
-function formatDate(dateStr: string | null): string {
+function formatInventoryDate(dateStr: string | null, dateFormat: DateFormat = 'system'): string {
   if (!dateStr) return '-';
   if (!dateStr) return '-';
-  const date = new Date(dateStr);
-  return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: '2-digit' });
+  const date = parseUTCDate(dateStr);
+  if (!date) return '-';
+  return formatDateInput(date, dateFormat);
 }
 }
 
 
 type CellCtx = {
 type CellCtx = {
@@ -112,6 +115,7 @@ type CellCtx = {
   pct: number;
   pct: number;
   assignmentMap: Record<number, SpoolAssignment>;
   assignmentMap: Record<number, SpoolAssignment>;
   currencySymbol: string;
   currencySymbol: string;
+  dateFormat: DateFormat;
 };
 };
 
 
 // Column header labels (25 columns — matching SpoolBuddy exactly)
 // Column header labels (25 columns — matching SpoolBuddy exactly)
@@ -148,14 +152,14 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
   id: ({ spool }) => (
   id: ({ spool }) => (
     <span className="text-sm font-medium text-white">{spool.id}</span>
     <span className="text-sm font-medium text-white">{spool.id}</span>
   ),
   ),
-  added_time: ({ spool }) => (
-    <span className="text-sm text-bambu-gray">{formatDate(spool.created_at)}</span>
+  added_time: ({ spool, dateFormat }) => (
+    <span className="text-sm text-bambu-gray">{formatInventoryDate(spool.created_at, dateFormat)}</span>
   ),
   ),
-  encode_time: ({ spool }) => (
-    <span className="text-sm text-bambu-gray">{formatDate(spool.encode_time)}</span>
+  encode_time: ({ spool, dateFormat }) => (
+    <span className="text-sm text-bambu-gray">{formatInventoryDate(spool.encode_time, dateFormat)}</span>
   ),
   ),
-  last_used_time: ({ spool }) => (
-    <span className="text-sm text-bambu-gray">{spool.last_used ? formatDate(spool.last_used) : 'Never'}</span>
+  last_used_time: ({ spool, dateFormat }) => (
+    <span className="text-sm text-bambu-gray">{spool.last_used ? formatInventoryDate(spool.last_used, dateFormat) : 'Never'}</span>
   ),
   ),
   rgba: ({ spool }) => (
   rgba: ({ spool }) => (
     <div className="flex items-center justify-center">
     <div className="flex items-center justify-center">
@@ -187,12 +191,12 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
     const assignment = assignmentMap[spool.id];
     const assignment = assignmentMap[spool.id];
     if (!assignment) return <span className="text-sm text-bambu-gray">-</span>;
     if (!assignment) return <span className="text-sm text-bambu-gray">-</span>;
     const printerLabel = assignment.printer_name || `Printer ${assignment.printer_id}`;
     const printerLabel = assignment.printer_name || `Printer ${assignment.printer_id}`;
-    // Bambu slot notation: AMS 0=A, 1=B, 2=C, 3=D; tray 0-based → 1-based
-    const slotLetter = String.fromCharCode(65 + assignment.ams_id);
-    const slotNumber = assignment.tray_id + 1;
+    const isExternal = assignment.ams_id === 254 || assignment.ams_id === 255;
+    const isHt = !isExternal && assignment.ams_id >= 128;
+    const slotLabel = formatSlotLabel(assignment.ams_id, assignment.tray_id, isHt, isExternal);
     return (
     return (
       <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400">
       <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400">
-        {printerLabel} {slotLetter}{slotNumber}
+        {printerLabel} {slotLabel}
       </span>
       </span>
     );
     );
   },
   },
@@ -276,7 +280,9 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
   location: (s, am) => {
   location: (s, am) => {
     const a = am[s.id];
     const a = am[s.id];
     if (!a) return '';
     if (!a) return '';
-    return `${a.printer_name || ''} ${String.fromCharCode(65 + a.ams_id)}${a.tray_id + 1}`;
+    const isExt = a.ams_id === 254 || a.ams_id === 255;
+    const isHt = !isExt && a.ams_id >= 128;
+    return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}`;
   },
   },
   label_weight: (s) => s.label_weight,
   label_weight: (s) => s.label_weight,
   net: (s) => Math.max(0, s.label_weight - s.weight_used),
   net: (s) => Math.max(0, s.label_weight - s.weight_used),
@@ -340,6 +346,13 @@ export default function InventoryPage() {
     return 15;
     return 15;
   });
   });
 
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const dateFormat: DateFormat = settings?.date_format || 'system';
+
   const { data: spools, isLoading } = useQuery({
   const { data: spools, isLoading } = useQuery({
     queryKey: ['inventory-spools'],
     queryKey: ['inventory-spools'],
     queryFn: () => api.getSpools(true), // Always fetch all, filter client-side
     queryFn: () => api.getSpools(true), // Always fetch all, filter client-side
@@ -944,7 +957,7 @@ export default function InventoryPage() {
                       >
                       >
                         {visibleColumns.map((colId) => (
                         {visibleColumns.map((colId) => (
                           <td key={colId} className="py-3 px-4">
                           <td key={colId} className="py-3 px-4">
-                            {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol })}
+                            {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat })}
                           </td>
                           </td>
                         ))}
                         ))}
                         <td className="py-3 px-4">
                         <td className="py-3 px-4">

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


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


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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-nSSxwAwH.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-pLsuHUG_.css">
+    <script type="module" crossorigin src="/assets/index-uDg4paLx.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DJax8qcY.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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