Просмотр исходного кода

feat: Unify print dispatch through the scheduler (#1625)

Ed 2 месяцев назад
Родитель
Сommit
4c67d8a4e1
63 измененных файлов с 1537 добавлено и 4882 удалено
  1. 1 0
      CHANGELOG.md
  2. 5 1
      CONTRIBUTING.md
  3. 15 87
      backend/app/api/routes/archives.py
  4. 0 32
      backend/app/api/routes/background_dispatch.py
  5. 21 100
      backend/app/api/routes/library.py
  6. 54 13
      backend/app/api/routes/print_queue.py
  7. 2 4
      backend/app/api/routes/settings.py
  8. 0 9
      backend/app/api/routes/websocket.py
  9. 10 0
      backend/app/core/database.py
  10. 5 5
      backend/app/core/permissions.py
  11. 0 7
      backend/app/main.py
  12. 4 0
      backend/app/models/print_queue.py
  13. 0 22
      backend/app/schemas/archive.py
  14. 0 27
      backend/app/schemas/library.py
  15. 6 0
      backend/app/schemas/print_queue.py
  16. 0 1100
      backend/app/services/background_dispatch.py
  17. 1 1
      backend/app/services/bambu_mqtt.py
  18. 60 13
      backend/app/services/print_scheduler.py
  19. 3 4
      backend/app/services/slice_dispatch.py
  20. 19 289
      backend/tests/integration/test_background_dispatch_api.py
  21. 118 3
      backend/tests/integration/test_ownership_permissions.py
  22. 193 0
      backend/tests/integration/test_print_queue_api.py
  23. 0 420
      backend/tests/unit/services/test_background_dispatch.py
  24. 0 721
      backend/tests/unit/services/test_background_dispatch_watchdog.py
  25. 3 4
      backend/tests/unit/services/test_bambu_mqtt.py
  26. 276 0
      backend/tests/unit/test_scheduler_cleanup_library.py
  27. 187 123
      frontend/src/__tests__/components/PrintModal.test.tsx
  28. 115 8
      frontend/src/__tests__/components/PrintModalDispatchToast.test.tsx
  29. 2 82
      frontend/src/__tests__/contexts/ToastContext.test.tsx
  30. 0 105
      frontend/src/__tests__/hooks/useDispatchedPrinterIds.test.ts
  31. 12 6
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  32. 6 6
      frontend/src/__tests__/pages/ProjectDetailPage.test.tsx
  33. 5 68
      frontend/src/api/client.ts
  34. 2 2
      frontend/src/components/PrintModal/PlateSelector.tsx
  35. 0 9
      frontend/src/components/PrintModal/PrinterSelector.tsx
  36. 43 22
      frontend/src/components/PrintModal/ScheduleOptions.tsx
  37. 122 227
      frontend/src/components/PrintModal/index.tsx
  38. 7 6
      frontend/src/components/PrintModal/types.ts
  39. 2 3
      frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx
  40. 24 532
      frontend/src/contexts/ToastContext.tsx
  41. 0 85
      frontend/src/hooks/useDispatchedPrinterIds.ts
  42. 0 8
      frontend/src/hooks/useWebSocket.ts
  43. 4 2
      frontend/src/i18n/index.ts
  44. 14 49
      frontend/src/i18n/locales/de.ts
  45. 14 49
      frontend/src/i18n/locales/en.ts
  46. 14 49
      frontend/src/i18n/locales/es.ts
  47. 14 49
      frontend/src/i18n/locales/fr.ts
  48. 14 49
      frontend/src/i18n/locales/it.ts
  49. 14 49
      frontend/src/i18n/locales/ja.ts
  50. 15 49
      frontend/src/i18n/locales/ko.ts
  51. 14 49
      frontend/src/i18n/locales/pt-BR.ts
  52. 14 48
      frontend/src/i18n/locales/tr.ts
  53. 14 49
      frontend/src/i18n/locales/zh-CN.ts
  54. 14 49
      frontend/src/i18n/locales/zh-TW.ts
  55. 25 59
      frontend/src/pages/ArchivesPage.tsx
  56. 13 94
      frontend/src/pages/FileManagerPage.tsx
  57. 9 3
      frontend/src/pages/PrintersPage.tsx
  58. 4 28
      frontend/src/pages/ProjectDetailPage.tsx
  59. 1 1
      frontend/src/pages/QueuePage.tsx
  60. 1 0
      static/assets/index-BKwIZ5yr.css
  61. 0 0
      static/assets/index-CkDEALWj.js
  62. 0 1
      static/assets/index-DIWYFok8.css
  63. 2 2
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -22,6 +22,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Appliance locale defaults endpoint** — `GET /api/v1/system/appliance` returns the hostname/timezone/locale the Bambuddy Appliance setup wizard collects into `/etc/bambuddy/local.toml` during firstboot. New `backend/app/core/local_config.py::read_local_toml` parses the file defensively (missing file → empty dict, invalid TOML → empty dict + warning, non-string values dropped with a warning), so a malformed file never blocks startup. Endpoint returns `{hostname, timezone, locale}` with `null` for any field not present, requires no auth (the frontend i18n bootstrap fetches it before auth might be set up, and the contents are user-set defaults, not secrets). On the frontend, `i18n/index.ts` runs a one-shot `applyApplianceLocale()` hook after init: gated by a `bambuddy_appliance_locale_consumed` localStorage flag so it runs exactly once per appliance, fetches the endpoint, and `i18n.changeLanguage(...)`s if the returned locale is in the supported set. Non-appliance installs (Docker, manual) silently no-op when the file or endpoint is absent. The appliance writes the file via its setup wizard (separate repo: `bambuddy-appliance`); this PR closes the loop for the locale field — hostname and timezone are still applied by the appliance's firstboot.sh via `hostnamectl`/`timedatectl` and don't need a main-app reader. Backend test coverage: 9 unit cases for the reader (missing/empty/comment-only/full/partial/invalid/non-string/unknown-keys/escaped-quotes), 4 integration cases for the endpoint (nulls when no file, full values, partial values, no-auth-required).
 
 ### Security
+- **Print permission scope change for queue-only dispatch** — All UI print-entry points now route through the print queue instead of direct background dispatch, which changes the permissions needed for some actions. File Manager **Print** now requires `queue:create` (previously `printers:control`). Printer-card upload-and-print now requires both `library:upload` and `queue:create`. Archive reprint buttons now require `queue:create` plus the existing archive reprint ownership permission (`archives:reprint_own` or `archives:reprint_all`). Installations with custom groups/API keys that granted `printers:control` for immediate printing but did not grant `queue:create` must add `queue:create` to keep those print actions available. Grant `queue:create` carefully: ASAP queue items are eligible for immediate dispatch, so it is now the permission that authorizes starting queued prints from File Manager, Archives, and upload-and-print flows.
 - **Vite 7 → 8 major bump** — Bambuddy's frontend now builds with Vite 8 (`^7.3.2` → `^8.0.16`) and the matching plugin-react release (`@vitejs/plugin-react` `^5.1.1` → `^5.2.0`). Headline architectural change: Vite 8 swaps Rollup for **Rolldown** as the default bundler — same plugin contract, Rust-backed core, slightly different chunk layout / output bytes (no functional regression). The bump also lifts the transitive `esbuild` floor to 0.28.1, which closes the last open advisory in the audit chain. **Bambuddy-side surface audited:** `vite.config.ts` uses only stable contracts that survived the v8 cut — `defineConfig`, the `Connect` type, the custom `serveGcodeViewer` `configureServer` middleware plugin (proxies `/gcode-viewer/*` to the repo's sibling `gcode_viewer/` directory in dev), the `server.proxy` with WebSocket upgrade for `/api/v1/ws`, `build.outDir`/`emptyOutDir`/`chunkSizeWarningLimit`, and `resolve.alias` for `@`. `base: '/'` regression guard from #1221 is unaffected. No SSR, no library mode, no CSS preprocessors, no exotic plugins. `vitest@4.1.8` already accepts vite 8 in its peer range (`^6 || ^7 || ^8`); no test-runner bump required. **Node:** vite 8 requires `^20.19.0 || >=22.12.0`; CI Node 20.x line satisfies this. **What this is NOT:** plugin-react v6 — that line requires `babel-plugin-react-compiler` + `@rolldown/plugin-babel` as peers and is a separate scope. `npm run build`, `npm run lint`, `npx vitest run` all clean; `npm audit` clean.
 - **Frontend dependency bumps** — Routine version updates across the runtime, build, and test dependency surface. **Runtime:** `dompurify` 3.4.0 → 3.4.10. `package.json` floor raised from `^3.4.0` to `^3.4.10` so fresh installs cannot land on the deprecated 3.4.4 release. Three call sites use string-output sanitisation (`frontend/src/pages/MakerworldPage.tsx`, `frontend/src/pages/ProjectDetailPage.tsx`, `frontend/src/components/ProjectPageModal.tsx`); release notes 3.4.1 → 3.4.10 reviewed for behavioural changes — 3.4.4 widened the default allow-list with `selectedcontent` + `command` + `commandfor` (all valid modern HTML, harmless for our two default-allow-list call sites), and `ProjectPageModal` is unaffected anyway because it sets an explicit `ALLOWED_TAGS` / `ALLOWED_ATTR` whitelist. **Build / lint / test tooling (transitive, dev-only):** `@babel/core` 7.29.0 → 7.29.7 (pulled by `@vitejs/plugin-react` and `eslint-plugin-react-hooks`), `vite` 7.3.2 → 7.3.5, `markdown-it` 14.1.1 → 14.2.0 (pulled by `@tiptap/extension-link` → `@tiptap/pm` → `prosemirror-markdown`; Bambuddy never calls `markdown-it.render` directly so the change is transparent), `js-yaml` 4.1.1 → 4.2.0 (pulled by `eslint`), `form-data` 4.0.5 → 4.0.6 + `ws` 8.20.1 → 8.21.0 (both pulled by `jsdom` in the test runtime). All bumps inside existing semver ranges except `dompurify`. No source changes required.
 - **`dompurify` 3.4.10 → 3.4.11** — Follow-up patch closes a moderate-severity advisory affecting `setConfig()` callers: the previous hook clone-guard added in 3.4.7 could be bypassed via `setConfig()`, leaving a permanent `ALLOWED_ATTR` pollution that the next `sanitize()` call inherited. **Bambuddy's exposure is nil** — `git grep DOMPurify.setConfig` returns zero hits across the entire codebase; all three sanitisation sites (`frontend/src/pages/MakerworldPage.tsx`, `frontend/src/pages/ProjectDetailPage.tsx`, `frontend/src/components/ProjectPageModal.tsx`) call `DOMPurify.sanitize(html)` or `DOMPurify.sanitize(html, {ALLOWED_TAGS, ALLOWED_ATTR})` directly, never through `setConfig()`. The bump is taken as defence-in-depth to keep XSS-sensitive surface area current and to silence `npm audit` so future audit-fix runs don't auto-bundle unintended changes. **Mechanical lockfile bump only:** the existing `^3.4.10` range already permitted 3.4.11, so `package.json` is unchanged; `package-lock.json` updates the resolved URL + integrity hash for the one entry. Verification: `npm audit` reports 0 vulnerabilities, `MakerworldPage.test.tsx`'s 12 DOMPurify sanitisation cases pass, `npm run build` clean.

+ 5 - 1
CONTRIBUTING.md

@@ -295,7 +295,11 @@ Permissions follow the `resource:action` pattern (e.g., `filaments:read`, `print
 | `update` | Modify existing resources |
 | `delete` | Remove resources |
 
-Some resources have additional actions (e.g., `printers:control` for start/stop, `printers:files` for file transfer).
+Some resources have additional actions. Examples: `printers:control` for live printer controls
+such as stop/pause/resume, `printers:files` for printer storage access, `queue:create` for
+creating queue items that may dispatch immediately when scheduled ASAP, `library:upload` for
+File Manager uploads/imports, and `archives:reprint_own` / `archives:reprint_all` for archive
+reprint eligibility. Archive reprint still needs `queue:create` before it can enqueue a job.
 
 ### Adding New Permissions
 

+ 15 - 87
backend/app/api/routes/archives.py

@@ -25,7 +25,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
-from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
+from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate
 from backend.app.schemas.print_log import PrintLogResponse
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
@@ -4025,96 +4025,24 @@ async def slice_archive(
 async def reprint_archive(
     archive_id: int,
     printer_id: int,
-    body: ReprintRequest | None = None,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
-        require_ownership_permission(
-            Permission.ARCHIVES_REPRINT_ALL,
-            Permission.ARCHIVES_REPRINT_OWN,
-        )
-    ),
+    # SECURITY.md SEC-AUTH-1: every route either has an explicit auth dep or
+    # is in the route-auth-coverage allowlist. Gating the deprecation stub on
+    # QUEUE_CREATE matches the replacement route (POST /queue/) and means
+    # anonymous callers bounce at auth instead of seeing the deprecation
+    # message — leaking "this route exists" to unauthenticated callers is
+    # exactly the shape the backstop guards against.
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
-    """Dispatch an archived 3MF file for send/start on a printer."""
-    from backend.app.models.printer import Printer
-    from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
-    from backend.app.services.printer_manager import printer_manager
-
-    user, can_modify_all = auth_result
-
-    # Use defaults if no body provided
-    if body is None:
-        body = ReprintRequest()
-
-    # Get archive
-    service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
-
-    # Ownership check
-    if not can_modify_all:
-        if archive.created_by_id != user.id:
-            raise HTTPException(403, "You can only reprint your own archives")
-
-    # Get printer
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
-
-    # Check printer is connected
-    if not printer_manager.is_connected(printer_id):
-        raise HTTPException(400, "Printer is not connected")
-
-    if not archive.file_path:
-        raise HTTPException(
-            404,
-            "No 3MF file available for this archive. "
-            "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
-    if not file_path.is_file():
-        raise HTTPException(404, "Archive file not found")
-
-    plate_name = body.plate_name
-    if not plate_name and body.plate_id is not None:
-        plate_name = f"Plate {body.plate_id}"
-
-    dispatch_source_name = archive.filename
-    if plate_name:
-        dispatch_source_name = f"{archive.filename} • {plate_name}"
-
-    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,
-        )
-    except DispatchEnqueueRejected as e:
-        raise HTTPException(status_code=409, detail=str(e)) from e
-
-    logger.info(
-        "Dispatched reprint archive %s for printer %s (dispatch_job_id=%s, dispatch_position=%s)",
+    """Legacy direct reprint endpoint. Use POST /queue/ instead."""
+    logger.warning(
+        "Gone API used: POST /archives/%s/reprint?printer_id=%s; use POST /queue/ instead",
         archive_id,
         printer_id,
-        dispatch_result["dispatch_job_id"],
-        dispatch_result["dispatch_position"],
     )
-
-    return {
-        "status": "dispatched",
-        "printer_id": printer_id,
-        "archive_id": archive_id,
-        "filename": archive.filename,
-        "dispatch_job_id": dispatch_result["dispatch_job_id"],
-        "dispatch_position": dispatch_result["dispatch_position"],
-    }
+    raise HTTPException(
+        status_code=410,
+        detail="Direct archive reprint has been removed. Create a print queue item with POST /queue/.",
+    )
 
 
 # =============================================================================

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

@@ -1,32 +0,0 @@
-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"],
-    }

+ 21 - 100
backend/app/api/routes/library.py

@@ -49,7 +49,6 @@ from backend.app.schemas.library import (
     FileDuplicate,
     FileListResponse,
     FileMoveRequest,
-    FilePrintRequest,
     FileResponse as FileResponseSchema,
     FileUpdate,
     FileUploadResponse,
@@ -196,11 +195,11 @@ def validate_print_file_upload(filename: str, content: bytes) -> None:
     — raw ``.gcode`` and corrupt/non-zip ``.3mf`` uploads cascade into a
     confusing "Printing stopped because the printer was unable to parse the
     3mf file" rejection 30 seconds after the user clicks Print. The
-    background dispatcher (``background_dispatch.py``) appends ``.3mf`` to
-    a raw-gcode filename when constructing the FTP destination, which is
-    how the printer ends up with a file named ``.gcode.3mf`` whose body is
-    raw gcode — exactly the shape that triggers the firmware parse
-    failure. Catching both classes here gives an actionable error at the
+    the queue dispatch path appends ``.3mf`` to a raw-gcode filename when
+    constructing the FTP destination, which is how the printer ends up with a
+    file named ``.gcode.3mf`` whose body is raw gcode — exactly the shape that
+    triggers the firmware parse failure. Catching both classes here gives an
+    actionable error at the
     upload itself.
 
     Compares the filename suffix rather than ``os.path.splitext`` because
@@ -4117,101 +4116,23 @@ async def slice_library_file(
 async def print_library_file(
     file_id: int,
     printer_id: int,
-    body: FilePrintRequest | None = None,
-    db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
+    # SECURITY.md SEC-AUTH-1: every route either has an explicit auth dep or
+    # is in the route-auth-coverage allowlist. Gating the deprecation stub on
+    # QUEUE_CREATE matches the replacement route (POST /queue/) and means
+    # anonymous callers bounce at auth instead of seeing the deprecation
+    # message.
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
 ):
-    """Dispatch a library file for send/start on a printer.
-
-    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.
-    """
-    from backend.app.models.printer import Printer
-    from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
-    from backend.app.services.printer_manager import printer_manager
-
-    # Use defaults if no body provided
-    if body is None:
-        body = FilePrintRequest()
-
-    # Get the library file
-    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
-    lib_file = result.scalar_one_or_none()
-
-    if not lib_file:
-        raise HTTPException(status_code=404, detail="File not found")
-
-    # Validate file is sliced
-    if not is_sliced_file(lib_file.filename):
-        raise HTTPException(
-            status_code=400,
-            detail="Not a sliced file. Only .gcode or .gcode.3mf files can be printed.",
-        )
-
-    # Filenames containing FAT32/exFAT-illegal characters would 553 at
-    # FTP upload time (#1540). Older rows may pre-date the rename-time
-    # validation, so reject the print attempt with an actionable message
-    # rather than silently renaming user data.
-    try:
-        validate_print_filename(lib_file.filename)
-    except InvalidFilenameError as e:
-        raise HTTPException(status_code=400, detail=str(e)) from e
-
-    # Get the full file path
-    file_path = Path(app_settings.base_dir) / lib_file.file_path
-
-    if not file_path.exists():
-        raise HTTPException(status_code=404, detail="File not found on disk")
-
-    # Get printer
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(status_code=404, detail="Printer not found")
-
-    # Check printer is connected
-    if not printer_manager.is_connected(printer_id):
-        raise HTTPException(status_code=400, detail="Printer is not connected")
-
-    # Validate project exists before dispatching so a bogus ID yields 404, not a FK-constraint 500
-    if body.project_id is not None:
-        project_result = await db.execute(select(Project).where(Project.id == body.project_id))
-        if not project_result.scalar_one_or_none():
-            raise HTTPException(status_code=404, detail="Project not found")
-
-    plate_name = body.plate_name
-    if not plate_name and body.plate_id is not None:
-        plate_name = f"Plate {body.plate_id}"
-
-    dispatch_source_name = lib_file.filename
-    if plate_name:
-        dispatch_source_name = f"{lib_file.filename} • {plate_name}"
-
-    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, exclude={"cleanup_library_after_dispatch"}),
-            project_id=body.project_id,
-            requested_by_user_id=current_user.id if current_user else None,
-            requested_by_username=current_user.username if current_user else None,
-            cleanup_library_after_dispatch=body.cleanup_library_after_dispatch,
-        )
-    except DispatchEnqueueRejected as e:
-        raise HTTPException(status_code=409, detail=str(e)) from e
-
-    return {
-        "status": "dispatched",
-        "printer_id": printer_id,
-        "archive_id": None,
-        "filename": lib_file.filename,
-        "dispatch_job_id": dispatch_result["dispatch_job_id"],
-        "dispatch_position": dispatch_result["dispatch_position"],
-    }
+    """Legacy direct library print endpoint. Use POST /queue/ instead."""
+    logger.warning(
+        "Gone API used: POST /library/files/%s/print?printer_id=%s; use POST /queue/ instead",
+        file_id,
+        printer_id,
+    )
+    raise HTTPException(
+        status_code=410,
+        detail="Direct library-file print has been removed. Create a print queue item with POST /queue/.",
+    )
 
 
 # ============ File Detail Endpoints ============

+ 54 - 13
backend/app/api/routes/print_queue.py

@@ -8,7 +8,7 @@ from pathlib import Path
 
 import defusedxml.ElementTree as ET
 from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy import and_, func, or_, select
+from sqlalchemy import and_, func, or_, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -153,6 +153,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
             nozzle_mapping_parsed = None
 
+    nozzles_info_parsed = None
+    if item.nozzles_info:
+        try:
+            nozzles_info_parsed = json.loads(item.nozzles_info)
+        except json.JSONDecodeError:
+            nozzles_info_parsed = None
+
     # Create response with parsed ams_mapping
     item_dict = {
         "id": item.id,
@@ -197,6 +204,8 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "gcode_injection": item.gcode_injection,
         # H2C rack-swap nozzle pick (#1780)
         "nozzle_mapping": nozzle_mapping_parsed,
+        "nozzles_info": nozzles_info_parsed,
+        "cleanup_library_after_dispatch": item.cleanup_library_after_dispatch,
     }
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
@@ -397,6 +406,23 @@ async def add_to_queue(
             and archive.created_by_id != current_user.id
         ):
             raise HTTPException(404, "Archive not found")
+        # Reprint perm gate (#1625): the legacy /archives/{id}/reprint endpoint
+        # required ARCHIVES_REPRINT_OWN/ALL; the unified queue route must keep
+        # that gate or an operator with QUEUE_CREATE could reprint via direct
+        # API call even if explicitly denied reprint perm. Mirrors the
+        # frontend `canModify('archives', 'reprint', ...)` helper:
+        # REPRINT_ALL allows any archive, REPRINT_OWN allows own only,
+        # ownerless archives require REPRINT_ALL (fail-closed).
+        if current_user is not None:
+            owns_archive = archive.created_by_id is not None and archive.created_by_id == current_user.id
+            has_reprint = current_user.has_permission(Permission.ARCHIVES_REPRINT_ALL.value) or (
+                owns_archive and current_user.has_permission(Permission.ARCHIVES_REPRINT_OWN.value)
+            )
+            if not has_reprint:
+                raise HTTPException(
+                    status_code=403,
+                    detail="Permission archives:reprint_own or archives:reprint_all required",
+                )
 
     # Validate library file exists (if provided) and get it for filament extraction
     library_file = None
@@ -502,21 +528,35 @@ async def add_to_queue(
         await db.flush()  # Get batch.id before creating items
         batch_id = batch.id
 
-    # Get next position for this printer (or for unassigned/model-based items)
+    # Get queue scope for this printer (or for unassigned/model-based items).
     if data.printer_id is not None:
-        result = await db.execute(
-            select(func.max(PrintQueueItem.position))
-            .where(PrintQueueItem.printer_id == data.printer_id)
-            .where(PrintQueueItem.status == "pending")
+        queue_scope = (
+            PrintQueueItem.printer_id == data.printer_id,
+            PrintQueueItem.status == "pending",
         )
     else:
-        # For unassigned/model-based items, get max position across all unassigned
-        result = await db.execute(
-            select(func.max(PrintQueueItem.position))
-            .where(PrintQueueItem.printer_id.is_(None))
-            .where(PrintQueueItem.status == "pending")
+        # For unassigned/model-based items, scope across all unassigned.
+        queue_scope = (
+            PrintQueueItem.printer_id.is_(None),
+            PrintQueueItem.status == "pending",
         )
-    max_pos = result.scalar() or 0
+
+    insert_position = max(1, data.insert_position or 1)
+    if data.insert_at_top or data.insert_position is not None:
+        result = await db.execute(select(func.max(PrintQueueItem.position)).where(*queue_scope))
+        max_pos = result.scalar() or 0
+        insert_position = min(insert_position, max_pos + 1)
+        await db.execute(
+            update(PrintQueueItem)
+            .where(*queue_scope)
+            .where(PrintQueueItem.position >= insert_position)
+            .values(position=PrintQueueItem.position + quantity)
+        )
+        start_position = insert_position
+    else:
+        result = await db.execute(select(func.max(PrintQueueItem.position)).where(*queue_scope))
+        max_pos = result.scalar() or 0
+        start_position = max_pos + 1
 
     # Resolve print_time_seconds for SJF scheduling (cache on item at creation)
     cached_print_time = None
@@ -571,8 +611,9 @@ async def add_to_queue(
             use_ams=data.use_ams,
             nozzle_offset_cali=data.nozzle_offset_cali,
             gcode_injection=data.gcode_injection,
+            cleanup_library_after_dispatch=data.cleanup_library_after_dispatch,
             project_id=data.project_id,
-            position=max_pos + 1 + i,
+            position=start_position + i,
             status="pending",
             created_by_id=current_user.id if current_user else None,
             batch_id=batch_id,

+ 2 - 4
backend/app/api/routes/settings.py

@@ -974,8 +974,8 @@ async def restore_backup(
             # 3b. Pause timer-based background services BEFORE the DB swap.
             # close_all_connections() below only disposes the engine's pool,
             # not the asyncio tasks that opened sessions from it. The print
-            # scheduler (30 s cadence), smart-plug snapshot loop (30 s),
-            # notification digest loop, and background dispatch worker all
+            # scheduler (30 s cadence), smart-plug snapshot loop (30 s), and
+            # notification digest loop all
             # wake up and call async_session(), which lazily re-creates a
             # pool connection holding RowExclusiveLock on print_queue /
             # smart_plug_energy_snapshots / etc. The DROP TABLE CASCADE
@@ -984,7 +984,6 @@ async def restore_backup(
             # full restore rollback. Successful restore already requires a
             # container restart, so we don't restart the services here.
             try:
-                from backend.app.services.background_dispatch import background_dispatch
                 from backend.app.services.notification_service import notification_service
                 from backend.app.services.print_scheduler import scheduler as print_scheduler
                 from backend.app.services.smart_plug_manager import smart_plug_manager
@@ -993,7 +992,6 @@ async def restore_backup(
                 print_scheduler.stop()
                 smart_plug_manager.stop_scheduler()
                 notification_service.stop_digest_scheduler()
-                await background_dispatch.stop()
                 # In-flight loop iterations need a moment to commit + release
                 # their DB sessions before we dispose() the engine pool.
                 await asyncio.sleep(1.0)

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

@@ -23,7 +23,6 @@ from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
 from backend.app.core.auth import is_auth_enabled, verify_websocket_token
 from backend.app.core.database import async_session
 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
 
 logger = logging.getLogger(__name__)
@@ -106,14 +105,6 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
                 }
             )
 
-        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))
 
         # Keep connection alive and handle incoming messages.

+ 10 - 0
backend/app/core/database.py

@@ -948,6 +948,16 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT false")
 
+    # Migration: cleanup flag for transient printer-card uploads routed through
+    # the scheduler. The archive copy is durable; the library row/file can be
+    # deleted after dispatch.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN cleanup_library_after_dispatch BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(
+            conn, "ALTER TABLE print_queue ADD COLUMN cleanup_library_after_dispatch BOOLEAN DEFAULT false"
+        )
+
     # Migration: Add queue_force_color_match column to virtual_printers (#1188).
     # Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
     # from the 3MF onto the queue item's filament_overrides so the scheduler

+ 5 - 5
backend/app/core/permissions.py

@@ -19,7 +19,7 @@ class Permission(StrEnum):
     PRINTERS_CREATE = "printers:create"
     PRINTERS_UPDATE = "printers:update"
     PRINTERS_DELETE = "printers:delete"
-    PRINTERS_CONTROL = "printers:control"  # Start/stop/pause/resume prints
+    PRINTERS_CONTROL = "printers:control"  # Printer controls: stop/pause/resume, lights, motors, drying, etc.
     PRINTERS_FILES = "printers:files"  # Send files to printer
     PRINTERS_AMS_RFID = "printers:ams_rfid"  # Re-read AMS RFID tags
     PRINTERS_CLEAR_PLATE = "printers:clear_plate"  # Confirm plate cleared for next print
@@ -36,15 +36,15 @@ class Permission(StrEnum):
     ARCHIVES_UPDATE_ALL = "archives:update_all"
     ARCHIVES_DELETE_OWN = "archives:delete_own"
     ARCHIVES_DELETE_ALL = "archives:delete_all"
-    ARCHIVES_REPRINT_OWN = "archives:reprint_own"
-    ARCHIVES_REPRINT_ALL = "archives:reprint_all"
+    ARCHIVES_REPRINT_OWN = "archives:reprint_own"  # Reprint own archives; queue:create is also required to enqueue
+    ARCHIVES_REPRINT_ALL = "archives:reprint_all"  # Reprint any archive; queue:create is also required to enqueue
     ARCHIVES_PURGE = "archives:purge"
 
     # Queue
     QUEUE_READ = "queue:read"
     QUEUE_READ_OWN = "queue:read_own"
     QUEUE_READ_ALL = "queue:read_all"
-    QUEUE_CREATE = "queue:create"
+    QUEUE_CREATE = "queue:create"  # Create queue items, including ASAP items eligible for immediate dispatch
     QUEUE_UPDATE_OWN = "queue:update_own"
     QUEUE_UPDATE_ALL = "queue:update_all"
     QUEUE_DELETE_OWN = "queue:delete_own"
@@ -55,7 +55,7 @@ class Permission(StrEnum):
     LIBRARY_READ = "library:read"
     LIBRARY_READ_OWN = "library:read_own"
     LIBRARY_READ_ALL = "library:read_all"
-    LIBRARY_UPLOAD = "library:upload"
+    LIBRARY_UPLOAD = "library:upload"  # Upload/import/slice library files; queue:create is also required to print
     LIBRARY_UPDATE_OWN = "library:update_own"
     LIBRARY_UPDATE_ALL = "library:update_all"
     LIBRARY_DELETE_OWN = "library:delete_own"

+ 0 - 7
backend/app/main.py

@@ -23,7 +23,6 @@ from backend.app.api.routes import (
     archive_purge,
     archives,
     auth,
-    background_dispatch as background_dispatch_routes,
     bug_report,
     camera,
     cloud,
@@ -81,7 +80,6 @@ from backend.app.core.websocket import ws_manager
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
 from backend.app.services.archive_purge import archive_purge_service
-from backend.app.services.background_dispatch import background_dispatch
 from backend.app.services.bambu_ftp import (
     FileNotOnPrinterError,
     cache_3mf_download,
@@ -6197,9 +6195,6 @@ async def lifespan(app: FastAPI):
     # Start the print scheduler
     spawn_background_task(print_scheduler.run(), name="print-scheduler")
 
-    # Start background dispatch worker for send/start operations
-    await background_dispatch.start()
-
     # Start the smart plug scheduler for time-based on/off
     smart_plug_manager.start_scheduler()
 
@@ -6265,7 +6260,6 @@ async def lifespan(app: FastAPI):
 
     # Shutdown
     print_scheduler.stop()
-    await background_dispatch.stop()
     smart_plug_manager.stop_scheduler()
     notification_service.stop_digest_scheduler()
     github_backup_service.stop_scheduler()
@@ -6740,7 +6734,6 @@ 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(print_log.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(notifications.router, prefix=app_settings.api_prefix)
 app.include_router(notification_templates.router, prefix=app_settings.api_prefix)

+ 4 - 0
backend/app/models/print_queue.py

@@ -77,6 +77,10 @@ class PrintQueueItem(Base):
     nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
     nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
 
+    # Printer-card direct uploads create transient library rows. When this is
+    # true, the scheduler deletes the source row/files after archiving a copy.
+    cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # Print options
     bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
     flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)

+ 0 - 22
backend/app/schemas/archive.py

@@ -219,25 +219,3 @@ class ProjectPageUpdate(BaseModel):
     copyright: str | None = None
     profile_title: str | None = None
     profile_description: str | None = None
-
-
-class ReprintRequest(BaseModel):
-    """Request body for reprinting an archive."""
-
-    # Plate selection for multi-plate 3MF files
-    # If not specified, auto-detects from file (legacy behavior for single-plate files)
-    plate_id: int | None = None
-    plate_name: str | None = None
-
-    # AMS slot mapping: list of tray IDs for each filament slot in the 3MF
-    # Global tray ID = (ams_id * 4) + slot_id, external = 254
-    ams_mapping: list[int] | None = None
-
-    # Print options
-    bed_levelling: bool = True
-    flow_cali: bool = False
-    vibration_cali: bool = True
-    layer_inspect: bool = False
-    timelapse: bool = False
-    use_ams: bool = True  # Not exposed in UI, but needed for API
-    nozzle_offset_cali: bool = True  # Dual-nozzle printers only — MQTT-gated (#1682)

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

@@ -278,33 +278,6 @@ class FileMoveRequest(BaseModel):
     folder_id: int | None = None  # None = move to root
 
 
-class FilePrintRequest(BaseModel):
-    """Schema for printing a file from the library.
-
-    Note: printer_id is passed as a query parameter, not in the body.
-    """
-
-    # Print options (same as archive reprint)
-    plate_id: int | None = None
-    plate_name: str | None = None
-    ams_mapping: list[int] | None = None
-    bed_levelling: bool = True
-    flow_cali: bool = False
-    vibration_cali: bool = True
-    layer_inspect: bool = False
-    timelapse: bool = False
-    use_ams: bool = True
-    nozzle_offset_cali: bool = True  # Dual-nozzle printers only — MQTT-gated (#1682)
-    # Project to associate the resulting archive with
-    project_id: int | None = None
-    # When true, delete the LibraryFile row + disk file after the archive has
-    # been created and the print has been dispatched. Used by the Printers-page
-    # Direct-Print flow (click / drag-drop a file onto a printer card) so the
-    # transient upload doesn't linger in File Manager. Cleanup is skipped on
-    # external library files.
-    cleanup_library_after_dispatch: bool = False
-
-
 class FileUploadResponse(BaseModel):
     """Schema for file upload response."""
 

+ 6 - 0
backend/app/schemas/print_queue.py

@@ -28,6 +28,8 @@ class PrintQueueItemCreate(BaseModel):
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
     manual_start: bool = False  # Requires manual trigger to start (staged)
+    insert_at_top: bool = False  # Insert ahead of other pending items in the same queue scope
+    insert_position: int | None = None  # 1-indexed insertion position for priority queueing
     # Persistent "Print Anyway" acknowledgement (#1698-followup). When set,
     # PrintModal already showed the deficit warning and the user confirmed,
     # so the scheduler does not re-flag this item on the next tick.
@@ -58,6 +60,9 @@ class PrintQueueItemCreate(BaseModel):
     batch_id: int | None = None
     # Project to associate the resulting archive with
     project_id: int | None = None
+    # Direct printer-card uploads are temporary library files. The scheduler
+    # deletes them after creating the durable archive copy.
+    cleanup_library_after_dispatch: bool = False
 
 
 class PrintQueueItemUpdate(BaseModel):
@@ -166,6 +171,7 @@ class PrintQueueItemResponse(BaseModel):
 
     # Auto-print G-code injection
     gcode_injection: bool = False
+    cleanup_library_after_dispatch: bool = False
 
     # H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
     # "edit print → choose nozzle" UI; null on every model except O1C2

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

@@ -1,1100 +0,0 @@
-"""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.tasks import spawn_background_task
-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 (
-    cache_3mf_download,
-    delete_file_async,
-    get_ftp_retry_settings,
-    upload_file_async,
-    with_ftp_retry,
-)
-from backend.app.services.printer_manager import printer_manager
-from backend.app.utils.filename import derive_remote_filename
-
-logger = logging.getLogger(__name__)
-
-# Bambu firmware states that mean the project_file has actually been accepted
-# and the printer is now processing / running / paused mid-print. Used by the
-# direct-dispatch verifier (#1370): a transition into one of these states means
-# the print landed, anything else (e.g. FINISH -> IDLE after the user dismisses
-# a post-print prompt) is NOT a valid "command landed" signal even though the
-# state value did change. Mirrors the same constant in print_scheduler.py —
-# kept duplicated rather than imported to avoid coupling the two services and
-# to keep the value at the point of use.
-_ACTIVE_PRINT_STATES: frozenset[str] = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
-
-
-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
-    project_id: int | None = None
-    cleanup_library_after_dispatch: bool = False
-
-
-@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,
-        project_id: int | None = None,
-        cleanup_library_after_dispatch: bool = False,
-    ) -> 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,
-            project_id=project_id,
-            cleanup_library_after_dispatch=cleanup_library_after_dispatch,
-        )
-
-    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,
-        project_id: int | None = None,
-        cleanup_library_after_dispatch: bool = False,
-    ) -> 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,
-                project_id=project_id,
-                cleanup_library_after_dispatch=cleanup_library_after_dispatch,
-            )
-            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")
-
-            remote_filename = derive_remote_filename(archive.filename)
-            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: spawn_background_task(
-                                self._set_active_upload_progress(job, u, t),
-                                name=f"upload-progress-{job.id}",
-                            )
-                        )
-
-                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)."
-                    )
-
-                # Resolve plate_id before register so usage tracking can scope the
-                # 3MF parse to the dispatched plate at print-start (#1697). Pure
-                # transform of file_path + options, safe to reorder.
-                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
-
-                register_expected_print(
-                    job.printer_id,
-                    remote_filename,
-                    job.source_id,
-                    ams_mapping=job.options.get("ams_mapping"),
-                    plate_id=plate_id,
-                )
-
-                self._raise_if_cancel_requested(job)
-
-                effective_timelapse = bool(job.options.get("timelapse", False))
-
-                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=effective_timelapse,
-                    bed_levelling=job.options.get("bed_levelling", True),
-                    flow_cali=job.options.get("flow_cali", False),
-                    vibration_cali=job.options.get("vibration_cali", True),
-                    layer_inspect=job.options.get("layer_inspect", False),
-                    use_ams=job.options.get("use_ams", True),
-                    nozzle_offset_cali=job.options.get("nozzle_offset_cali", False),
-                )
-
-                if not started:
-                    await self._cleanup_sd_card_file(
-                        printer_ip,
-                        printer_access_code,
-                        remote_path,
-                        printer_model,
-                    )
-                    raise RuntimeError("Failed to start print")
-
-                # Register the archive's local 3MF in the cover-cache so the
-                # /cover endpoint can skip FTP — we already have the file on
-                # disk, no need to refetch 36 MB from a printer whose FTP is
-                # busy serving the active print (#1166 follow-up).
-                cache_3mf_download(job.printer_id, remote_filename, file_path)
-
-                # Wait for the printer to actually pick up the command before
-                # marking the dispatch job complete (#1042). MQTT-publish success
-                # only proves the command queued locally; the printer can still
-                # reject it (HMS error pending, half-broken session, SD card
-                # missing) and never transition. Until #1042 this watchdog was
-                # fire-and-forget — the job was reported successful and the
-                # user had no signal that the print never started. The uploaded
-                # file is intentionally left on the printer's SD card on
-                # timeout: the next dispatch will overwrite it via the existing
-                # delete-then-upload step, and the printer may still be in the
-                # middle of reading it if it picked up just past the timeout.
-                pre_status = printer_manager.get_status(job.printer_id)
-                pre_state = getattr(pre_status, "state", None) if pre_status else None
-                pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
-                pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
-                if pre_state:
-                    await self._set_active_message(job, f"Waiting for {printer_name} to acknowledge print...")
-                    transitioned = await self._verify_print_response(
-                        job.printer_id,
-                        printer_name,
-                        pre_state,
-                        pre_subtask_id=pre_subtask_id,
-                        pre_gcode_file=pre_gcode_file,
-                    )
-                    if not transitioned:
-                        raise RuntimeError(
-                            f"Printer did not acknowledge print command — state still {pre_state}. "
-                            f"Check the printer for a pending error (HMS code, plate-clear prompt, "
-                            f"SD card) and try again."
-                        )
-
-                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(LibraryFile.active().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,
-                original_filename=lib_file.filename,
-                project_id=job.project_id,
-                created_by_id=job.requested_by_user_id,
-            )
-            if not archive:
-                raise RuntimeError("Failed to create archive")
-
-            await db.flush()
-
-            remote_filename = derive_remote_filename(lib_file.filename)
-            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: spawn_background_task(
-                                self._set_active_upload_progress(job, u, t),
-                                name=f"upload-progress-{job.id}",
-                            )
-                        )
-
-                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)."
-                    )
-
-                # Resolve plate_id before register so usage tracking can scope the
-                # 3MF parse to the dispatched plate at print-start (#1697).
-                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
-
-                register_expected_print(
-                    job.printer_id,
-                    remote_filename,
-                    archive.id,
-                    ams_mapping=job.options.get("ams_mapping"),
-                    plate_id=plate_id,
-                )
-
-                self._raise_if_cancel_requested(job)
-
-                effective_timelapse = bool(job.options.get("timelapse", False))
-
-                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=effective_timelapse,
-                    bed_levelling=job.options.get("bed_levelling", True),
-                    flow_cali=job.options.get("flow_cali", False),
-                    vibration_cali=job.options.get("vibration_cali", True),
-                    layer_inspect=job.options.get("layer_inspect", False),
-                    use_ams=job.options.get("use_ams", True),
-                    nozzle_offset_cali=job.options.get("nozzle_offset_cali", False),
-                )
-
-                if not started:
-                    await self._cleanup_sd_card_file(
-                        printer_ip,
-                        printer_access_code,
-                        remote_path,
-                        printer_model,
-                    )
-                    await db.rollback()
-                    raise RuntimeError("Failed to start print")
-
-                # Same as the archive path: register the library file's local
-                # 3MF in the cover-cache so /cover skips FTP (#1166 follow-up).
-                cache_3mf_download(job.printer_id, remote_filename, file_path)
-
-                # See _run_reprint_archive for rationale (#1042). On timeout
-                # also rolls back the freshly-created archive so the library
-                # flow doesn't leave behind a phantom row for a print that
-                # never started.
-                pre_status = printer_manager.get_status(job.printer_id)
-                pre_state = getattr(pre_status, "state", None) if pre_status else None
-                pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
-                pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
-                if pre_state:
-                    await self._set_active_message(job, f"Waiting for {printer_name} to acknowledge print...")
-                    transitioned = await self._verify_print_response(
-                        job.printer_id,
-                        printer_name,
-                        pre_state,
-                        pre_subtask_id=pre_subtask_id,
-                        pre_gcode_file=pre_gcode_file,
-                    )
-                    if not transitioned:
-                        await db.rollback()
-                        raise RuntimeError(
-                            f"Printer did not acknowledge print command — state still {pre_state}. "
-                            f"Check the printer for a pending error (HMS code, plate-clear prompt, "
-                            f"SD card) and try again."
-                        )
-
-                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,
-                    )
-
-                # Direct-Print flow only: archive_print copies, so deleting the
-                # transient library row + files here leaves archive intact. Disk
-                # deletes run only after commit so a rollback leaves no orphan.
-                cleanup_disk_paths: list[Path] = []
-                if job.cleanup_library_after_dispatch and not lib_file.is_external:
-                    cleanup_disk_paths.append(file_path)
-                    if lib_file.thumbnail_path:
-                        thumb_path = Path(lib_file.thumbnail_path)
-                        if not thumb_path.is_absolute():
-                            thumb_path = Path(settings.base_dir) / lib_file.thumbnail_path
-                        cleanup_disk_paths.append(thumb_path)
-                    await db.delete(lib_file)
-
-                await db.commit()
-
-                for cleanup_path in cleanup_disk_paths:
-                    try:
-                        if cleanup_path.exists():
-                            cleanup_path.unlink()
-                    except OSError as cleanup_err:
-                        logger.warning("Failed to delete transient library file %s: %s", cleanup_path, cleanup_err)
-            except DispatchJobCancelled:
-                await db.rollback()
-                await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
-                raise
-
-    @staticmethod
-    async def _verify_print_response(
-        printer_id: int,
-        printer_name: str,
-        pre_state: str,
-        pre_subtask_id: str | None = None,
-        pre_gcode_file: str | None = None,
-        timeout: float = 90.0,
-        poll_interval: float = 3.0,
-    ) -> bool:
-        """Wait for the printer to acknowledge a print command.
-
-        Returns True if the printer transitioned (state advanced past pre_state
-        or subtask_id advanced past pre_subtask_id). Returns False on timeout —
-        in that case logs a warning and forces an MQTT reconnect, mirroring the
-        queue-side watchdog (`_watchdog_print_start`). Caller is responsible
-        for surfacing the False result to the user (typically by raising so the
-        dispatch job is marked failed).
-
-        Both transition signals are checked because H2D can sit at FINISH for
-        ~50 s after accepting `project_file` before flipping to PREPARE; the
-        printer echoes our per-dispatch identity back as `subtask_id` on
-        `push_status` first, so a subtask_id change is a definitive "command
-        landed" signal even while state is still FINISH (#1078).
-        """
-        deadline = time.monotonic() + timeout
-        last_status = None
-        while time.monotonic() < deadline:
-            await asyncio.sleep(poll_interval)
-            state = printer_manager.get_status(printer_id)
-            if not state:
-                # Printer momentarily not reporting — could be a brief MQTT
-                # disconnect mid-window. Keep polling rather than declaring
-                # failure on the first missed tick; the printer may reconnect
-                # within the remaining timeout and still surface a transition.
-                continue
-            last_status = state
-            if state.state in _ACTIVE_PRINT_STATES:
-                # Printer is actively processing the job. We do NOT accept
-                # arbitrary state transitions: a printer going FINISH -> IDLE
-                # (user dismissed the post-print prompt without accepting our
-                # project_file) would otherwise look like "command landed"
-                # and the dispatch job would be marked successful even though
-                # no print is running (#1370).
-                return True
-            if pre_subtask_id is not None and state.subtask_id is not None and state.subtask_id != pre_subtask_id:
-                # Printer picked up the job (subtask_id advanced). H2D can
-                # sit at FINISH for ~50 s after accepting project_file before
-                # transitioning to PREPARE, but the subtask_id flips to our
-                # submission_id almost immediately (#1078).
-                return True
-        logger.warning(
-            "Printer %s (%d) did not respond to print command within %.0fs "
-            "(state still %s, subtask_id still %s) — printer may need restart",
-            printer_name,
-            printer_id,
-            timeout,
-            pre_state,
-            pre_subtask_id,
-        )
-        # Distinguish #1150 (slow parse) from #887/#936 (half-broken session)
-        # via gcode_file: if the printer is now showing a different file than
-        # before dispatch, the project_file command landed and the printer is
-        # parsing — a forced reconnect mid-parse causes 0500_4003. If
-        # gcode_file is unchanged, the publish was silently swallowed and the
-        # original #936 recovery (force_reconnect → fresh client_id) is what
-        # we want. Caveat: in the rare retry-same-file-after-timeout case the
-        # printer's gcode_file looks identical before and after the publish
-        # lands, so a slow parse on retry-same-file still falls through to the
-        # reconnect (and the original 0500_4003) — accepted to avoid breaking
-        # the half-broken-session recovery path.
-        client = printer_manager.get_client(printer_id)
-        current_gcode_file = getattr(last_status, "gcode_file", None) if last_status else None
-        publish_landed = current_gcode_file is not None and current_gcode_file != pre_gcode_file
-        if publish_landed:
-            logger.warning(
-                "Printer %s (%d) gcode_file changed to %r (was %r) — printer "
-                "received the command and is parsing slowly. Skipping forced "
-                "MQTT reconnect to avoid 0500_4003 mid-parse (#1150).",
-                printer_name,
-                printer_id,
-                current_gcode_file,
-                pre_gcode_file,
-            )
-        elif client and hasattr(client, "force_reconnect_stale_session"):
-            client.force_reconnect_stale_session(
-                f"print command unacknowledged after {timeout:.0f}s "
-                f"(state still {pre_state}, gcode_file {current_gcode_file!r})"
-            )
-        return False
-
-    @staticmethod
-    async def _cleanup_sd_card_file(
-        printer_ip: str,
-        access_code: str,
-        remote_path: str,
-        printer_model: str | None,
-    ):
-        """Best-effort delete of uploaded file from printer SD card."""
-        try:
-            await delete_file_async(printer_ip, access_code, remote_path, printer_model=printer_model)
-        except Exception:
-            pass  # Best-effort — don't fail the error handler
-
-    @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()

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

@@ -680,7 +680,7 @@ class BambuMQTTClient:
         #
         # Two routing paths:
         #
-        # Async-context callers (background_dispatch.py:993 — dispatch deadline)
+        # Async-context callers (queue dispatch deadline)
         #   → full client teardown + fresh client_id. Wipes paho's client-side
         #     QoS 1 queue, which is exactly the #1136 reproducer: an unacked
         #     `project_file` from the broken session would otherwise replay on

+ 60 - 13
backend/app/services/print_scheduler.py

@@ -2117,6 +2117,7 @@ class PrintScheduler:
         library_file = None
         file_path = None
         filename = None
+        cleanup_disk_paths: list[Path] = []
 
         if item.archive_id:
             # Print from archive
@@ -2152,6 +2153,7 @@ class PrintScheduler:
             filename = library_file.filename
 
             # Create archive from library file so usage tracking has access to the 3MF
+            queue_item_id = item.id
             try:
                 from backend.app.services.archive import ArchiveService
 
@@ -2165,6 +2167,17 @@ class PrintScheduler:
                 )
                 if archive:
                     item.archive_id = archive.id
+                    if item.cleanup_library_after_dispatch and not library_file.is_external:
+                        item.library_file_id = None
+                        cleanup_disk_paths.append(file_path)
+                        if library_file.thumbnail_path:
+                            thumb_path = Path(library_file.thumbnail_path)
+                            if not thumb_path.is_absolute():
+                                thumb_path = settings.base_dir / library_file.thumbnail_path
+                            cleanup_disk_paths.append(thumb_path)
+                        await db.delete(library_file)
+                        file_path = settings.base_dir / archive.file_path
+                        filename = archive.filename
                     await db.flush()
                     logger.info(
                         "Queue item %s: Created archive %s from library file %s",
@@ -2173,7 +2186,30 @@ class PrintScheduler:
                         item.library_file_id,
                     )
             except Exception as e:
-                logger.warning("Queue item %s: Failed to create archive from library file: %s", item.id, e)
+                logger.warning(
+                    "Queue item %s: Failed to create archive from library file: %s",
+                    queue_item_id,
+                    e,
+                    exc_info=True,
+                )
+                await db.rollback()
+                item = await db.get(PrintQueueItem, queue_item_id)
+                if item:
+                    item.status = "failed"
+                    item.error_message = "Failed to create archive from library file"
+                    item.completed_at = datetime.now(timezone.utc)
+                    await db.commit()
+                    await self._power_off_if_needed(db, item)
+                return
+
+            if not archive:
+                item.status = "failed"
+                item.error_message = "Failed to create archive from library file"
+                item.completed_at = datetime.now(timezone.utc)
+                await db.commit()
+                logger.error("Queue item %s: Archive creation from library file returned no archive", item.id)
+                await self._power_off_if_needed(db, item)
+                return
 
         else:
             # Neither archive nor library file specified
@@ -2329,12 +2365,8 @@ class PrintScheduler:
 
         # Propagate the queue item's owner into printer_manager so the
         # print-complete callback can credit the user in the PrintLogEntry
-        # (#1670). The dispatch path in `background_dispatch.py` does the
-        # equivalent for archive/library "Print" flows; the queue path was
-        # missing this hop, which left the print log's User column blank
-        # for any print started from the queue. `created_by_id` is set
-        # either at queue-add time (UI-added items) or when the user
-        # clicks the manual-start button (#1670 fix in print_queue.py).
+        # (#1670). `created_by_id` is set either at queue-add time (UI-added
+        # items) or when the user clicks the manual-start button.
         await self._propagate_owner_to_printer_manager(db, item)
 
         # IMPORTANT: Set status to "printing" BEFORE sending the print command.
@@ -2347,6 +2379,23 @@ class PrintScheduler:
         item.started_at = datetime.now(timezone.utc)
         await db.commit()
 
+        for cleanup_path in cleanup_disk_paths:
+            try:
+                if cleanup_path.exists():
+                    cleanup_path.unlink()
+            except OSError as cleanup_err:
+                logger.warning(
+                    "TRANSIENT_LIBRARY_FILE_ORPHAN %s",
+                    json.dumps(
+                        {
+                            "queue_item_id": item.id,
+                            "path": str(cleanup_path),
+                            "error": str(cleanup_err),
+                        },
+                        sort_keys=True,
+                    ),
+                )
+
         # Clear the awaiting-plate-clear flag now that we're starting a new print
         printer_manager.set_awaiting_plate_clear(item.printer_id, False)
         logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
@@ -2643,13 +2692,11 @@ class PrintScheduler:
         if landed_on_subtask:
             return
 
-        # Phase A timeout path — same #1150 / #887/#936 discriminator as
-        # background_dispatch: if the printer's gcode_file changed since
+        # Phase A timeout path: if the printer's gcode_file changed since
         # pre-dispatch, the project_file command landed and the printer is
-        # parsing — a forced reconnect mid-parse triggers 0500_4003. If
-        # gcode_file is unchanged, the publish was silently swallowed
-        # (#887/#936) and the original force_reconnect recovery is what we
-        # want.
+        # parsing — a forced reconnect mid-parse triggers 0500_4003 (#1150).
+        # If gcode_file is unchanged, the publish was silently swallowed
+        # (#887/#936) and force_reconnect recovery is what we want.
         client = printer_manager.get_client(printer_id)
         current_gcode_file = getattr(last_status, "gcode_file", None) if last_status else None
         publish_landed = current_gcode_file is not None and current_gcode_file != pre_gcode_file

+ 3 - 4
backend/app/services/slice_dispatch.py

@@ -1,9 +1,8 @@
 """In-memory background dispatcher for slice jobs.
 
-Mirrors the shape of `background_dispatch.py` (the print-upload dispatcher)
-but tailored for slicing: jobs are independent (no printer-busy gating),
-short-lived (typically 5-60s), and the result is a `LibraryFile` or
-`PrintArchive` row rather than a printer-side dispatch.
+Slice jobs are independent (no printer-busy gating), short-lived (typically
+5-60s), and the result is a `LibraryFile` or `PrintArchive` row rather than a
+printer-side dispatch.
 
 The frontend kicks off a slice via `POST /library/files/{id}/slice` or
 `POST /archives/{id}/slice`, gets back `{job_id, status_url}`, then polls

+ 19 - 289
backend/tests/integration/test_background_dispatch_api.py

@@ -1,306 +1,36 @@
-"""Integration tests for background dispatch API behavior."""
-
-from unittest.mock import AsyncMock, patch
+"""Integration tests for removed direct print API behavior."""
 
 import pytest
 from httpx import AsyncClient
 
-from backend.app.services.background_dispatch import DispatchEnqueueRejected
-
 
-class TestBackgroundDispatchArchivesAPI:
-    """Tests for archive reprint dispatch endpoint."""
+class TestLegacyArchivePrintAPI:
+    """Tests for the removed 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",
+    async def test_reprint_route_returns_410_and_does_not_dispatch(self, async_client: AsyncClient):
+        """Legacy direct reprint endpoint is gone; callers must create queue items."""
+        response = await async_client.post(
+            "/api/v1/archives/123/reprint?printer_id=456",
+            json={"plate_id": 2},
         )
 
-        archive_file = tmp_path / archive.file_path
-        archive_file.parent.mkdir(parents=True, exist_ok=True)
-        archive_file.write_bytes(b"3mf-data")
+        assert response.status_code == 410
+        assert "POST /queue/" in response.json()["detail"]
 
-        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
+class TestLegacyLibraryPrintAPI:
+    """Tests for the removed library print dispatch endpoint."""
 
     @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",
+    async def test_library_print_route_returns_410_and_does_not_dispatch(self, async_client: AsyncClient):
+        """Legacy direct library print endpoint is gone; callers must create queue items."""
+        response = await async_client.post(
+            "/api/v1/library/files/123/print?printer_id=456",
+            json={"plate_id": 4},
         )
 
-        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"]
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_library_print_cleanup_flag_defaults_false(
-        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
-    ):
-        """Absent cleanup_library_after_dispatch in the request body ⇒ False reaches the dispatcher.
-        Guards the File Manager / Project Detail paths from accidental deletion."""
-        printer = await printer_factory()
-        lib_file = await library_file_factory(filename="filemgr_part.gcode.3mf")
-
-        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": 30, "dispatch_position": 1}),
-            ) as mock_dispatch,
-        ):
-            response = await async_client.post(
-                f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
-                json={},
-            )
-
-        assert response.status_code == 200
-        mock_dispatch.assert_awaited_once()
-        assert mock_dispatch.await_args.kwargs["cleanup_library_after_dispatch"] is False
-        # cleanup flag must never leak into the print-option dict forwarded to MQTT
-        assert "cleanup_library_after_dispatch" not in mock_dispatch.await_args.kwargs["options"]
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_library_print_forwards_cleanup_flag_true(
-        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
-    ):
-        """Direct-Print flow sends cleanup_library_after_dispatch=True, which must reach the dispatcher."""
-        printer = await printer_factory()
-        lib_file = await library_file_factory(filename="transient_part.gcode.3mf")
-
-        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": 31, "dispatch_position": 1}),
-            ) as mock_dispatch,
-        ):
-            response = await async_client.post(
-                f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
-                json={"cleanup_library_after_dispatch": True},
-            )
-
-        assert response.status_code == 200
-        mock_dispatch.assert_awaited_once()
-        assert mock_dispatch.await_args.kwargs["cleanup_library_after_dispatch"] is True
-
-
-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"
+        assert response.status_code == 410
+        assert "POST /queue/" in response.json()["detail"]

+ 118 - 3
backend/tests/integration/test_ownership_permissions.py

@@ -287,15 +287,15 @@ class TestArchiveOwnershipPermissions(TestOwnershipPermissionsSetup):
         assert response.status_code == 403
 
     # ========================================================================
-    # REPRINT permissions
+    # Legacy reprint endpoint
     # ========================================================================
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_operator_cannot_reprint_others_archive(
+    async def test_reprint_endpoint_is_gone_for_all_callers(
         self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
     ):
-        """Operator cannot reprint another user's archive."""
+        """Direct archive reprint no longer exists; callers must use the queue."""
         printer = await printer_factory()
         archive = await archive_factory(
             printer.id,
@@ -307,7 +307,122 @@ class TestArchiveOwnershipPermissions(TestOwnershipPermissionsSetup):
             headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
         )
 
+        assert response.status_code == 410
+
+    # ========================================================================
+    # Queue route — archives:reprint_* gate (#1625)
+    # ========================================================================
+    # The unified /queue/ route replaced the legacy /reprint endpoint; the
+    # reprint permission gate must move with it. Without these checks a
+    # caller with QUEUE_CREATE + ARCHIVES_READ_OWN could reprint their own
+    # archives even if explicitly denied ARCHIVES_REPRINT_OWN.
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_route_operator_can_reprint_own_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Operator with REPRINT_OWN can queue their own archive."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"printer_id": printer.id, "archive_id": archive.id},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_route_user_without_reprint_gets_403(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """User with QUEUE_CREATE + ARCHIVES_READ_OWN but no reprint perm → 403.
+
+        Custom group mirrors a real operator policy where someone is allowed
+        to enqueue freshly-uploaded library files but explicitly NOT allowed
+        to re-run completed archives.
+        """
+        # Create custom group with queue:create + archives:read_own but no reprint perm.
+        admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
+        group_resp = await async_client.post(
+            "/api/v1/groups/",
+            headers=admin_headers,
+            json={
+                "name": "QueueOnlyNoReprint",
+                "description": "Test group: can queue library files but not reprint",
+                "permissions": [
+                    "queue:create",
+                    "queue:read_own",
+                    "archives:read_own",
+                    "library:read_own",
+                    "library:upload",
+                    "printers:read",
+                ],
+            },
+        )
+        assert group_resp.status_code in (200, 201)
+        group_id = group_resp.json()["id"]
+
+        await async_client.post(
+            "/api/v1/users/",
+            headers=admin_headers,
+            json={
+                "username": "noreprint_user",
+                "password": "NoreprintPass1!",
+                "group_ids": [group_id],
+            },
+        )
+        login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "noreprint_user", "password": "NoreprintPass1!"},
+        )
+        token = login.json()["access_token"]
+        user_id = login.json()["user"]["id"]
+
+        # Archive owned by the no-reprint user.
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, created_by_id=user_id)
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            headers={"Authorization": f"Bearer {token}"},
+            json={"printer_id": printer.id, "archive_id": archive.id},
+        )
+
         assert response.status_code == 403
+        assert "reprint" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_route_ownerless_archive_requires_reprint_all(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Ownerless archive (created_by_id=null) requires REPRINT_ALL.
+
+        Pre-IDOR-fix legacy data has no creator; an operator with
+        REPRINT_OWN can't fall back to "I own this" — fail-closed.
+        The existing IDOR check returns 404 first (operator lacks
+        READ_ALL and doesn't own the row), so this is also a regression
+        guard against accidentally surfacing 403-instead-of-404 if the
+        IDOR check is ever loosened.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, created_by_id=None)
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"printer_id": printer.id, "archive_id": archive.id},
+        )
+
+        # IDOR returns 404 before the new gate fires for this operator.
+        assert response.status_code == 404
 
 
 class TestQueueOwnershipPermissions(TestOwnershipPermissionsSetup):

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

@@ -1893,6 +1893,199 @@ class TestAbortedStatusNormalisation:
         positions = [i["position"] for i in batch_items]
         assert positions == [positions[0], positions[0] + 1, positions[0] + 2]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_insert_position_shifts_existing_items(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify priority insertion shifts existing pending items in the same printer queue."""
+        printer = await printer_factory()
+        first = await archive_factory(print_name="First")
+        second = await archive_factory(print_name="Second")
+        priority = await archive_factory(print_name="Priority")
+
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": first.id})
+        ).status_code == 200
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": second.id})
+        ).status_code == 200
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": priority.id,
+                "insert_position": 1,
+            },
+        )
+        assert response.status_code == 200
+
+        list_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}")
+        items = sorted(list_response.json(), key=lambda item: item["position"])
+        assert [item["archive_id"] for item in items[:3]] == [priority.id, first.id, second.id]
+        assert [item["position"] for item in items[:3]] == [1, 2, 3]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_insert_position_quantity_shifts_existing_by_quantity(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """ASAP batch insertion shifts existing pending items by the inserted quantity."""
+        printer = await printer_factory()
+        first = await archive_factory(print_name="First")
+        second = await archive_factory(print_name="Second")
+        priority = await archive_factory(print_name="Priority")
+
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": first.id})
+        ).status_code == 200
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": second.id})
+        ).status_code == 200
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": priority.id,
+                "quantity": 3,
+                "insert_position": 1,
+            },
+        )
+        assert response.status_code == 200
+        batch_id = response.json()["batch_id"]
+
+        list_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}")
+        items = sorted(list_response.json(), key=lambda item: item["position"])
+        assert [item["archive_id"] for item in items] == [
+            priority.id,
+            priority.id,
+            priority.id,
+            first.id,
+            second.id,
+        ]
+        assert [item["position"] for item in items] == [1, 2, 3, 4, 5]
+        assert [item["batch_id"] for item in items[:3]] == [batch_id, batch_id, batch_id]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_insert_position_scopes_unassigned_items(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Unassigned inserts shift only the unassigned queue scope."""
+        printer = await printer_factory()
+        unassigned_first = await archive_factory(print_name="Unassigned First")
+        unassigned_second = await archive_factory(print_name="Unassigned Second")
+        assigned = await archive_factory(print_name="Assigned")
+        priority = await archive_factory(print_name="Unassigned Priority")
+
+        assert (await async_client.post("/api/v1/queue/", json={"archive_id": unassigned_first.id})).status_code == 200
+        assert (await async_client.post("/api/v1/queue/", json={"archive_id": unassigned_second.id})).status_code == 200
+        assigned_response = await async_client.post(
+            "/api/v1/queue/",
+            json={"printer_id": printer.id, "archive_id": assigned.id},
+        )
+        assert assigned_response.status_code == 200
+        assert assigned_response.json()["position"] == 1
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "archive_id": priority.id,
+                "insert_position": 1,
+            },
+        )
+        assert response.status_code == 200
+
+        unassigned_response = await async_client.get("/api/v1/queue/?printer_id=-1")
+        unassigned_items = sorted(unassigned_response.json(), key=lambda item: item["position"])
+        assert [item["archive_id"] for item in unassigned_items] == [
+            priority.id,
+            unassigned_first.id,
+            unassigned_second.id,
+        ]
+        assert [item["position"] for item in unassigned_items] == [1, 2, 3]
+
+        assigned_scope_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}&target_model=NONE")
+        assigned_items = sorted(assigned_scope_response.json(), key=lambda item: item["position"])
+        assert [item["archive_id"] for item in assigned_items] == [assigned.id]
+        assert [item["position"] for item in assigned_items] == [1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_insert_position_greater_than_max_appends_without_gap(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Oversized explicit insert_position appends at max+1 instead of creating sparse positions."""
+        printer = await printer_factory()
+        first = await archive_factory(print_name="First")
+        second = await archive_factory(print_name="Second")
+        appended = await archive_factory(print_name="Append")
+
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": first.id})
+        ).status_code == 200
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": second.id})
+        ).status_code == 200
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": appended.id,
+                "insert_position": 99,
+            },
+        )
+        assert response.status_code == 200
+        assert response.json()["position"] == 3
+
+        list_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}")
+        items = sorted(list_response.json(), key=lambda item: item["position"])
+        assert [item["archive_id"] for item in items] == [first.id, second.id, appended.id]
+        assert [item["position"] for item in items] == [1, 2, 3]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_consecutive_asap_inserts_stack_in_submission_order(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Consecutive ASAP inserts to the same printer preserve the client submission order."""
+        printer = await printer_factory()
+        existing = await archive_factory(print_name="Existing")
+        first_asap = await archive_factory(print_name="First ASAP")
+        second_asap = await archive_factory(print_name="Second ASAP")
+
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": existing.id})
+        ).status_code == 200
+
+        first_response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": first_asap.id,
+                "insert_position": 1,
+            },
+        )
+        assert first_response.status_code == 200
+
+        second_response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": second_asap.id,
+                "insert_position": 2,
+            },
+        )
+        assert second_response.status_code == 200
+
+        list_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}")
+        items = sorted(list_response.json(), key=lambda item: item["position"])
+        assert [item["archive_id"] for item in items] == [first_asap.id, second_asap.id, existing.id]
+        assert [item["position"] for item in items] == [1, 2, 3]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_add_to_queue_quantity_with_print_options(

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

@@ -1,420 +0,0 @@
-"""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_dispatch_library_file_defaults_cleanup_flag_false():
-    """cleanup_library_after_dispatch defaults to False when not passed — protects
-    File Manager / Project Detail / queued-library-file paths from surprise deletion."""
-    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),
-    ):
-        await service.dispatch_print_library_file(
-            file_id=1,
-            filename="cube.gcode.3mf",
-            printer_id=1,
-            printer_name="Printer A",
-            options={},
-            requested_by_user_id=None,
-            requested_by_username=None,
-        )
-
-    assert len(service._queued_jobs) == 1
-    assert service._queued_jobs[0].cleanup_library_after_dispatch is False
-
-
-@pytest.mark.asyncio
-async def test_dispatch_library_file_propagates_cleanup_flag_true():
-    """cleanup_library_after_dispatch=True arrives on the queued job so the runner
-    can delete the transient LibraryFile after the print is accepted by the printer."""
-    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),
-    ):
-        await service.dispatch_print_library_file(
-            file_id=1,
-            filename="cube.gcode.3mf",
-            printer_id=1,
-            printer_name="Printer A",
-            options={},
-            requested_by_user_id=42,
-            requested_by_username="alice",
-            cleanup_library_after_dispatch=True,
-        )
-
-    assert len(service._queued_jobs) == 1
-    job = service._queued_jobs[0]
-    assert job.cleanup_library_after_dispatch is True
-    # Sanity: other fields still wired correctly
-    assert job.requested_by_user_id == 42
-    assert job.requested_by_username == "alice"
-    assert job.kind == "print_library_file"
-
-
-@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
-
-
-def test_dispatch_option_defaults_align_with_request_schema_defaults():
-    """The `job.options.get("<field>", <default>)` calls in the dispatch
-    loop must use the same default as the Pydantic request schema. If a
-    field is missing from options (e.g. an internal caller bypassing the
-    schema), the resulting print command must match what a fresh
-    `ReprintRequest()` / `FilePrintRequest()` would produce — anything
-    else means certain fields silently flip depending on which entry
-    point queued the job.
-
-    Earlier `vibration_cali` had a False default in the dispatch loop
-    against a True schema default, latent only because every existing
-    caller always sent the field.
-    """
-    import inspect
-
-    from backend.app.schemas.archive import ReprintRequest
-    from backend.app.schemas.library import FilePrintRequest
-    from backend.app.services import background_dispatch as bd
-
-    # `timelapse` deliberately excluded — the dispatcher wraps it in
-    # ``bool(...)`` (``effective_timelapse = bool(job.options.get("timelapse",
-    # False))``) so the bare-pattern needle in the loop below would miss it.
-    # The wrap exists to coerce None / non-bool option payloads to a bool
-    # boundary the printer firmware accepts (#1721 follow-up).
-    fields = ("bed_levelling", "flow_cali", "vibration_cali", "layer_inspect", "use_ams")
-    reprint_defaults = {f: getattr(ReprintRequest(), f) for f in fields}
-    libprint_defaults = {f: getattr(FilePrintRequest(), f) for f in fields}
-    assert reprint_defaults == libprint_defaults, (
-        "ReprintRequest and FilePrintRequest must share the same defaults for these fields"
-    )
-
-    src = inspect.getsource(bd)
-    for field, expected_default in reprint_defaults.items():
-        literal = "True" if expected_default else "False"
-        needle = f'{field}=job.options.get("{field}", {literal})'
-        count = src.count(needle)
-        assert count == 2, (
-            f"Expected exactly 2 occurrences of `{needle}` in background_dispatch (one per "
-            f"`_process_job` branch). Found {count}. A drift between the request schema's "
-            f"default for `{field}` and the dispatch loop's `.get()` default means callers "
-            f"that bypass the schema will get inconsistent behaviour."
-        )
-
-
-@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

+ 0 - 721
backend/tests/unit/services/test_background_dispatch_watchdog.py

@@ -1,721 +0,0 @@
-"""Regression tests for ``BackgroundDispatchService._verify_print_response``.
-
-The background-dispatch watchdog used to be fire-and-forget — it logged a
-warning and force-reconnected MQTT, but the dispatch job had already been
-marked successful. The user therefore saw "Print started successfully" while
-the printer never actually transitioned (#1042 follow-up). The watchdog now
-returns a bool so the caller can fail the dispatch job when the printer
-doesn't acknowledge the command, mirroring what `_watchdog_print_start` does
-on the queue side.
-
-Both transition signals are accepted: ``state`` advancing past ``pre_state``
-*or* ``subtask_id`` advancing past ``pre_subtask_id`` — H2D firmware can sit
-at FINISH for ~50 s after accepting ``project_file`` while echoing the new
-subtask_id back almost immediately (#1078).
-"""
-
-from types import SimpleNamespace
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from backend.app.services.background_dispatch import BackgroundDispatchService
-
-
-def _status(state: str, subtask_id: str | None = None, gcode_file: str | None = None):
-    """Minimal stand-in for PrinterState — only the fields the watchdog reads."""
-    return SimpleNamespace(state=state, subtask_id=subtask_id, gcode_file=gcode_file)
-
-
-class TestReturnsTrueOnPickup:
-    @pytest.mark.asyncio
-    async def test_returns_true_on_state_change(self):
-        get_status = MagicMock(return_value=_status("RUNNING", "OLD_SUBTASK"))
-        with patch(
-            "backend.app.services.background_dispatch.printer_manager.get_status",
-            get_status,
-        ):
-            result = await BackgroundDispatchService._verify_print_response(
-                printer_id=42,
-                printer_name="P1S",
-                pre_state="FINISH",
-                pre_subtask_id="OLD_SUBTASK",
-                timeout=0.3,
-                poll_interval=0.05,
-            )
-
-        assert result is True
-
-    @pytest.mark.asyncio
-    async def test_returns_true_on_subtask_id_change_even_if_state_still_finish(self):
-        """#1078: H2D keeps state=FINISH for ~50 s after accepting project_file
-        but flips subtask_id immediately. Must be accepted as a pickup signal."""
-        get_status = MagicMock(return_value=_status("FINISH", "NEW_SUBTASK_12345"))
-        with patch(
-            "backend.app.services.background_dispatch.printer_manager.get_status",
-            get_status,
-        ):
-            result = await BackgroundDispatchService._verify_print_response(
-                printer_id=42,
-                printer_name="H2D",
-                pre_state="FINISH",
-                pre_subtask_id="OLD_SUBTASK_99999",
-                timeout=0.3,
-                poll_interval=0.05,
-            )
-
-        assert result is True
-
-
-class TestReturnsFalseOnTimeout:
-    @pytest.mark.asyncio
-    async def test_returns_false_when_neither_state_nor_subtask_id_changes(self):
-        """The exact #1042 scenario: P1S sits in FAILED with HMS pending,
-        accepts the MQTT publish, never transitions. Watchdog must report
-        failure so the caller fails the dispatch job."""
-        get_status = MagicMock(return_value=_status("FINISH", "OLD_SUBTASK"))
-        client = MagicMock()
-        get_client = MagicMock(return_value=client)
-
-        with (
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_status",
-                get_status,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_client",
-                get_client,
-            ),
-        ):
-            result = await BackgroundDispatchService._verify_print_response(
-                printer_id=42,
-                printer_name="P1S",
-                pre_state="FINISH",
-                pre_subtask_id="OLD_SUBTASK",
-                timeout=0.2,
-                poll_interval=0.05,
-            )
-
-        assert result is False
-        client.force_reconnect_stale_session.assert_called_once()
-
-    @pytest.mark.asyncio
-    async def test_returns_false_on_finish_to_idle_user_dismissed_prompt(self):
-        """Regression for #1370 in the direct-dispatch path: when pre_state is
-        FINISH and the printer transitions to IDLE during the verifier window,
-        that's the user dismissing a post-print prompt — NOT acceptance of our
-        project_file. The original ``state != pre_state`` check incorrectly
-        returned True on this transition, so the dispatch job was marked
-        successful even though no print was running. Must now report failure
-        so the caller raises RuntimeError and the user sees the actual error.
-        """
-        get_status = MagicMock(return_value=_status("IDLE", "OLD_SUBTASK"))
-        client = MagicMock()
-        get_client = MagicMock(return_value=client)
-
-        with (
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_status",
-                get_status,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_client",
-                get_client,
-            ),
-        ):
-            result = await BackgroundDispatchService._verify_print_response(
-                printer_id=42,
-                printer_name="P1S",
-                pre_state="FINISH",
-                pre_subtask_id="OLD_SUBTASK",
-                timeout=0.2,
-                poll_interval=0.05,
-            )
-
-        assert result is False, (
-            "FINISH -> IDLE is the user dismissing a screen prompt, not the "
-            "printer accepting project_file — verifier must report failure (#1370)"
-        )
-
-    @pytest.mark.asyncio
-    async def test_returns_true_on_each_active_print_state(self):
-        """Counterpart to the #1370 fix: transitions into the active-print
-        state set ARE valid "command landed" signals. PREPARE / SLICING /
-        RUNNING / PAUSE all return True.
-        """
-        for active_state in ("PREPARE", "SLICING", "RUNNING", "PAUSE"):
-            get_status = MagicMock(return_value=_status(active_state, "OLD_SUBTASK"))
-            with patch(
-                "backend.app.services.background_dispatch.printer_manager.get_status",
-                get_status,
-            ):
-                result = await BackgroundDispatchService._verify_print_response(
-                    printer_id=42,
-                    printer_name="P1S",
-                    pre_state="IDLE",
-                    pre_subtask_id="OLD_SUBTASK",
-                    timeout=0.2,
-                    poll_interval=0.05,
-                )
-            assert result is True, (
-                f"transition IDLE -> {active_state} must be treated as a valid 'command landed' signal"
-            )
-
-    @pytest.mark.asyncio
-    async def test_returns_false_when_pre_subtask_id_none_and_state_unchanged(self):
-        """Backward-compat: callers without a captured pre_subtask_id (e.g. the
-        printer never reported one) must still get the timeout failure path
-        based on state alone."""
-        get_status = MagicMock(return_value=_status("FINISH", "ANYTHING"))
-        get_client = MagicMock(return_value=None)
-
-        with (
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_status",
-                get_status,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_client",
-                get_client,
-            ),
-        ):
-            result = await BackgroundDispatchService._verify_print_response(
-                printer_id=42,
-                printer_name="P1S",
-                pre_state="FINISH",
-                pre_subtask_id=None,
-                timeout=0.2,
-                poll_interval=0.05,
-            )
-
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_subtask_id_none_post_dispatch_does_not_count_as_change(self):
-        """If the printer transiently reports subtask_id=None during the
-        watchdog window (e.g. mid-reconnect), that must not be treated as
-        "advanced past pre_subtask_id" — otherwise we'd false-pass and mark
-        a never-started print as successful."""
-        get_status = MagicMock(return_value=_status("FINISH", None))
-        get_client = MagicMock(return_value=None)
-
-        with (
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_status",
-                get_status,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_client",
-                get_client,
-            ),
-        ):
-            result = await BackgroundDispatchService._verify_print_response(
-                printer_id=42,
-                printer_name="P1S",
-                pre_state="FINISH",
-                pre_subtask_id="OLD_SUBTASK",
-                timeout=0.2,
-                poll_interval=0.05,
-            )
-
-        assert result is False
-
-
-class TestDisconnectHandling:
-    @pytest.mark.asyncio
-    async def test_disconnect_does_not_short_circuit_window(self):
-        """A momentary ``get_status() is None`` (brief MQTT disconnect mid-window)
-        must not immediately fail the dispatch — the printer may reconnect and
-        still produce a valid transition before timeout. Falsely failing on the
-        first missed tick is the previous bug class we're moving away from."""
-        # First call: disconnected. Second call onward: reconnected and transitioned.
-        get_status = MagicMock(side_effect=[None, _status("RUNNING")])
-        with patch(
-            "backend.app.services.background_dispatch.printer_manager.get_status",
-            get_status,
-        ):
-            result = await BackgroundDispatchService._verify_print_response(
-                printer_id=42,
-                printer_name="P1S",
-                pre_state="FINISH",
-                pre_subtask_id="OLD_SUBTASK",
-                timeout=0.3,
-                poll_interval=0.05,
-            )
-
-        assert result is True
-        assert get_status.call_count >= 2
-
-    @pytest.mark.asyncio
-    async def test_disconnect_for_full_window_returns_false(self):
-        """Persistent disconnect for the full window is treated as failure.
-        Better to false-fail and let the user retry than to false-succeed and
-        leave them watching an idle printer (#1042)."""
-        get_status = MagicMock(return_value=None)
-        get_client = MagicMock(return_value=None)
-
-        with (
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_status",
-                get_status,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_client",
-                get_client,
-            ),
-        ):
-            result = await BackgroundDispatchService._verify_print_response(
-                printer_id=42,
-                printer_name="P1S",
-                pre_state="FINISH",
-                pre_subtask_id="OLD_SUBTASK",
-                timeout=0.2,
-                poll_interval=0.05,
-            )
-
-        assert result is False
-
-
-class TestDefaults:
-    def test_default_timeout_matches_queue_watchdog(self):
-        """Queue and background watchdogs need the same 90 s default to give
-        slow H2D FINISH→PREPARE transitions the same headroom on both paths."""
-        import inspect
-
-        sig = inspect.signature(BackgroundDispatchService._verify_print_response)
-        assert sig.parameters["timeout"].default == 90.0
-
-
-class TestGcodeFileDiscriminator:
-    """#1150 vs #887/#936 discriminator: skip the forced reconnect when the
-    printer's gcode_file changed since pre-dispatch (project_file landed,
-    printer is parsing slowly — reconnecting mid-parse causes 0500_4003).
-    Reconnect when gcode_file is unchanged (publish was silently swallowed —
-    half-broken session needs the original recovery)."""
-
-    @pytest.mark.asyncio
-    async def test_skips_reconnect_when_gcode_file_changed(self):
-        get_status = MagicMock(
-            return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/new.3mf"),
-        )
-        client = MagicMock()
-        get_client = MagicMock(return_value=client)
-
-        with (
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_status",
-                get_status,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_client",
-                get_client,
-            ),
-        ):
-            result = await BackgroundDispatchService._verify_print_response(
-                printer_id=42,
-                printer_name="P1P",
-                pre_state="FINISH",
-                pre_subtask_id="OLD_SUBTASK",
-                pre_gcode_file="/old.3mf",
-                timeout=0.2,
-                poll_interval=0.05,
-            )
-
-        assert result is False
-        client.force_reconnect_stale_session.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_reconnects_when_gcode_file_unchanged(self):
-        # The half-broken-session case (#887/#936): publish was dropped, so
-        # the printer is still showing the previous file. Reconnect to clear
-        # the broken paho QoS-1 queue.
-        get_status = MagicMock(
-            return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/old.3mf"),
-        )
-        client = MagicMock()
-        get_client = MagicMock(return_value=client)
-
-        with (
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_status",
-                get_status,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_client",
-                get_client,
-            ),
-        ):
-            await BackgroundDispatchService._verify_print_response(
-                printer_id=42,
-                printer_name="P1P",
-                pre_state="FINISH",
-                pre_subtask_id="OLD_SUBTASK",
-                pre_gcode_file="/old.3mf",
-                timeout=0.2,
-                poll_interval=0.05,
-            )
-
-        client.force_reconnect_stale_session.assert_called_once()
-
-    @pytest.mark.asyncio
-    async def test_skips_reconnect_when_pre_gcode_file_was_none(self):
-        # Printer just connected (pre_gcode_file=None) and now reports a
-        # file — that's a clear "command landed" signal too.
-        get_status = MagicMock(
-            return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/new.3mf"),
-        )
-        client = MagicMock()
-        get_client = MagicMock(return_value=client)
-
-        with (
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_status",
-                get_status,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_client",
-                get_client,
-            ),
-        ):
-            await BackgroundDispatchService._verify_print_response(
-                printer_id=42,
-                printer_name="P1P",
-                pre_state="FINISH",
-                pre_subtask_id="OLD_SUBTASK",
-                pre_gcode_file=None,
-                timeout=0.2,
-                poll_interval=0.05,
-            )
-
-        client.force_reconnect_stale_session.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_reconnects_when_no_pre_gcode_file_arg_supplied(self):
-        # Backward-compat: callers that don't pass pre_gcode_file at all
-        # (everything but our updated dispatch sites) must still get the
-        # original reconnect-on-timeout behaviour. Here pre_gcode_file
-        # defaults to None and the printer's current gcode_file is also
-        # None → publish_landed=False → reconnect.
-        get_status = MagicMock(
-            return_value=_status("FINISH", "OLD_SUBTASK", gcode_file=None),
-        )
-        client = MagicMock()
-        get_client = MagicMock(return_value=client)
-
-        with (
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_status",
-                get_status,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_client",
-                get_client,
-            ),
-        ):
-            await BackgroundDispatchService._verify_print_response(
-                printer_id=42,
-                printer_name="P1P",
-                pre_state="FINISH",
-                pre_subtask_id="OLD_SUBTASK",
-                timeout=0.2,
-                poll_interval=0.05,
-            )
-
-        client.force_reconnect_stale_session.assert_called_once()
-
-
-# ---------------------------------------------------------------------------
-# Integration tests: the call sites in _run_reprint_archive and
-# _run_print_library_file must (a) await the watchdog instead of fire-and-
-# forget, (b) raise RuntimeError on watchdog False so _run_active_job marks
-# the job failed, (c) rollback the library-file flow's freshly-created
-# archive on timeout. Heavy mocking — the goal is to verify the new wiring,
-# not to re-test the dependencies.
-# ---------------------------------------------------------------------------
-
-from contextlib import asynccontextmanager  # noqa: E402
-from unittest.mock import AsyncMock  # noqa: E402
-
-from backend.app.services.background_dispatch import (  # noqa: E402
-    ActiveDispatchState,
-    PrintDispatchJob,
-)
-
-
-def _make_session_factory(db_mock):
-    """Build an async-session factory whose context manager yields ``db_mock``.
-
-    Mirrors the ``async with async_session() as db`` shape used by both
-    ``_run_*`` methods so the test can intercept ``db.rollback`` / ``db.scalar``.
-    """
-
-    @asynccontextmanager
-    async def _factory():
-        yield db_mock
-
-    return _factory
-
-
-def _printer_namespace():
-    return SimpleNamespace(
-        id=10,
-        name="P1S",
-        ip_address="1.2.3.4",
-        access_code="abc",
-        model="P1S",
-    )
-
-
-def _make_dispatch_job(kind: str = "reprint_archive") -> PrintDispatchJob:
-    return PrintDispatchJob(
-        id=1,
-        kind=kind,
-        source_id=99,
-        source_name="Test.gcode.3mf",
-        printer_id=10,
-        printer_name="P1S",
-        options={},
-        requested_by_user_id=None,
-        requested_by_username=None,
-    )
-
-
-@pytest.fixture
-def reprint_archive_mocks(tmp_path):
-    """Mock harness for ``_run_reprint_archive`` covering every external
-    dependency up to (and including) ``start_print``. The watchdog is left
-    real so the caller can patch ``_verify_print_response`` per-test."""
-    archive_file = tmp_path / "test.3mf"
-    archive_file.write_bytes(b"fake-3mf-content")
-
-    archive = SimpleNamespace(
-        id=99,
-        filename="Test.gcode.3mf",
-        file_path=str(archive_file),
-    )
-
-    db = MagicMock()
-    db.scalar = AsyncMock(return_value=_printer_namespace())
-    db.rollback = AsyncMock()
-
-    archive_service = MagicMock()
-    archive_service.get_archive = AsyncMock(return_value=archive)
-
-    return {
-        "archive": archive,
-        "archive_file": archive_file,
-        "db": db,
-        "archive_service": archive_service,
-        "session_factory": _make_session_factory(db),
-    }
-
-
-@pytest.fixture
-def library_file_mocks(tmp_path):
-    """Mock harness for ``_run_print_library_file`` — separate from the
-    reprint fixture because the library flow creates its archive via
-    ``archive_service.archive_print(...)`` rather than fetching one."""
-    src_file = tmp_path / "lib_src.3mf"
-    src_file.write_bytes(b"fake-3mf-content")
-
-    lib_file = SimpleNamespace(
-        id=22,
-        filename="cube.gcode.3mf",
-        file_path=str(src_file.relative_to(tmp_path)),
-    )
-    lib_file.active = staticmethod(lambda: lib_file)  # mimic LibraryFile.active() chainable
-
-    new_archive = SimpleNamespace(id=500, filename="cube.gcode.3mf", file_path=str(src_file))
-
-    db = MagicMock()
-    db.scalar = AsyncMock()  # configured per-test
-    db.flush = AsyncMock()
-    db.commit = AsyncMock()
-    db.rollback = AsyncMock()
-
-    archive_service = MagicMock()
-    archive_service.archive_print = AsyncMock(return_value=new_archive)
-
-    return {
-        "lib_file": lib_file,
-        "src_file": src_file,
-        "new_archive": new_archive,
-        "db": db,
-        "archive_service": archive_service,
-        "session_factory": _make_session_factory(db),
-    }
-
-
-class TestReprintArchiveDispatchWiring:
-    """Verify ``_run_reprint_archive`` (a) awaits the watchdog inline and
-    (b) raises RuntimeError on False so the dispatch job is marked failed."""
-
-    @pytest.mark.asyncio
-    async def test_raises_runtime_error_when_watchdog_returns_false(self, reprint_archive_mocks):
-        """The exact #1042 propagation gap: watchdog detects non-transition,
-        _run_reprint_archive must surface it as a RuntimeError so the surrounding
-        _run_active_job marks the job failed (instead of silently completing)."""
-        from backend.app.services.background_dispatch import BackgroundDispatchService
-
-        m = reprint_archive_mocks
-        service = BackgroundDispatchService()
-        job = _make_dispatch_job(kind="reprint_archive")
-
-        watchdog = AsyncMock(return_value=False)
-
-        with (
-            patch("backend.app.services.background_dispatch.async_session", m["session_factory"]),
-            patch(
-                "backend.app.services.background_dispatch.ArchiveService",
-                return_value=m["archive_service"],
-            ),
-            patch.object(BackgroundDispatchService, "_verify_print_response", watchdog),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.is_connected",
-                return_value=True,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_status",
-                return_value=SimpleNamespace(state="FINISH", subtask_id="OLD_SUBTASK"),
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.start_print",
-                return_value=True,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.delete_file_async",
-                new_callable=AsyncMock,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.with_ftp_retry",
-                new_callable=AsyncMock,
-                return_value=True,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.get_ftp_retry_settings",
-                new_callable=AsyncMock,
-                return_value=(False, 0, 0, 30.0),
-            ),
-            patch(
-                "backend.app.services.background_dispatch.upload_file_async",
-                new_callable=AsyncMock,
-                return_value=True,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.ws_manager.broadcast",
-                new_callable=AsyncMock,
-            ),
-            patch("backend.app.main.register_expected_print"),
-            pytest.raises(RuntimeError, match="did not acknowledge print command"),
-        ):
-            await service._run_reprint_archive(job)
-
-        # Watchdog received the captured pre-state and pre_subtask_id.
-        watchdog.assert_awaited_once()
-        kwargs = watchdog.await_args.kwargs
-        args = watchdog.await_args.args
-        assert "FINISH" in args  # pre_state
-        assert kwargs["pre_subtask_id"] == "OLD_SUBTASK"
-
-    @pytest.mark.asyncio
-    async def test_succeeds_when_watchdog_returns_true(self, reprint_archive_mocks):
-        """Happy path: watchdog confirms pickup; _run_reprint_archive returns
-        without raising. Guards against the wiring accidentally raising on True."""
-        from backend.app.services.background_dispatch import BackgroundDispatchService
-
-        m = reprint_archive_mocks
-        service = BackgroundDispatchService()
-        job = _make_dispatch_job(kind="reprint_archive")
-
-        with (
-            patch("backend.app.services.background_dispatch.async_session", m["session_factory"]),
-            patch(
-                "backend.app.services.background_dispatch.ArchiveService",
-                return_value=m["archive_service"],
-            ),
-            patch.object(
-                BackgroundDispatchService,
-                "_verify_print_response",
-                AsyncMock(return_value=True),
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.is_connected",
-                return_value=True,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.get_status",
-                return_value=SimpleNamespace(state="FINISH", subtask_id="OLD_SUBTASK"),
-            ),
-            patch(
-                "backend.app.services.background_dispatch.printer_manager.start_print",
-                return_value=True,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.delete_file_async",
-                new_callable=AsyncMock,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.with_ftp_retry",
-                new_callable=AsyncMock,
-                return_value=True,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.get_ftp_retry_settings",
-                new_callable=AsyncMock,
-                return_value=(False, 0, 0, 30.0),
-            ),
-            patch(
-                "backend.app.services.background_dispatch.upload_file_async",
-                new_callable=AsyncMock,
-                return_value=True,
-            ),
-            patch(
-                "backend.app.services.background_dispatch.ws_manager.broadcast",
-                new_callable=AsyncMock,
-            ),
-            patch("backend.app.main.register_expected_print"),
-        ):
-            await service._run_reprint_archive(job)  # must not raise
-
-        # Reprint flow does not touch the existing archive — no rollback expected.
-        m["db"].rollback.assert_not_called()
-
-
-class TestRunActiveJobMarksFailedOnRuntimeError:
-    """End-to-end: a watchdog-driven RuntimeError must reach
-    `_mark_job_finished(failed=True)` via the existing ``_run_active_job``
-    catch-all, so the dispatch UI shows a real failure (not "Done")."""
-
-    @pytest.mark.asyncio
-    async def test_runtime_error_from_process_job_marks_failed_with_message(self):
-        from backend.app.services.background_dispatch import BackgroundDispatchService
-
-        service = BackgroundDispatchService()
-        job = _make_dispatch_job()
-        # Place the job into _active_jobs so _set_active_message has a target.
-        service._active_jobs[job.id] = ActiveDispatchState(job=job, message="")
-
-        failure_message = (
-            "Printer did not acknowledge print command — state still FINISH. "
-            "Check the printer for a pending error (HMS code, plate-clear prompt, "
-            "SD card) and try again."
-        )
-
-        with (
-            patch.object(
-                BackgroundDispatchService,
-                "_process_job",
-                AsyncMock(side_effect=RuntimeError(failure_message)),
-            ),
-            patch.object(
-                BackgroundDispatchService,
-                "_mark_job_finished",
-                new_callable=AsyncMock,
-            ) as mark_finished,
-        ):
-            await service._run_active_job(job)
-
-        mark_finished.assert_awaited_once()
-        kwargs = mark_finished.await_args.kwargs
-        assert kwargs["failed"] is True
-        assert "did not acknowledge print command" in kwargs["message"]

+ 3 - 4
backend/tests/unit/services/test_bambu_mqtt.py

@@ -4742,7 +4742,7 @@ class TestZombieSessionDetection:
         the routing in force_reconnect_stale_session falls back to socket-close
         — which is the safe option since loop_stop() from inside the loop
         thread would deadlock. Hard-reset is reserved for async-context callers
-        (background_dispatch dispatch path)."""
+        on the queue dispatch path."""
         import time
 
         state_change_called = []
@@ -5026,9 +5026,8 @@ class TestHardResetClientDirect:
 
     def test_swallows_disconnect_exception(self, mqtt_client):
         """A failing disconnect() (e.g. paho already in error state) must not
-        propagate — the await chain in background_dispatch.py would otherwise
-        raise instead of moving on, and a single broken client could brick
-        every future dispatch."""
+        propagate through async dispatch callers, and a single broken client
+        could brick every future dispatch."""
         original = mqtt_client._client
         original.disconnect.side_effect = RuntimeError("boom")
         # No exception escapes the call (test would fail if it did).

+ 276 - 0
backend/tests/unit/test_scheduler_cleanup_library.py

@@ -0,0 +1,276 @@
+from contextlib import ExitStack
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+import backend.app.services.print_scheduler as scheduler_module
+from backend.app.core.database import Base
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+async def queue_factory(tmp_path):
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+    case_counter = 0
+
+    async def make_case(*, cleanup=True, is_external=False, thumbnail_path=None):
+        nonlocal case_counter
+        case_counter += 1
+
+        base_dir = tmp_path / f"case-{case_counter}"
+        base_dir.mkdir()
+        source_path = base_dir / "library" / f"source-{case_counter}.3mf"
+        source_path.parent.mkdir()
+        source_path.write_bytes(b"library source")
+
+        thumbnail_actual_path = None
+        thumbnail_db_path = None
+        if thumbnail_path == "relative":
+            thumbnail_db_path = f"thumbs/preview-{case_counter}.png"
+            thumbnail_actual_path = base_dir / thumbnail_db_path
+        elif thumbnail_path == "absolute":
+            thumbnail_actual_path = tmp_path / f"absolute-preview-{case_counter}.png"
+            thumbnail_db_path = str(thumbnail_actual_path)
+        elif thumbnail_path is not None:
+            thumbnail_actual_path = Path(thumbnail_path)
+            thumbnail_db_path = str(thumbnail_path)
+
+        if thumbnail_actual_path:
+            thumbnail_actual_path.parent.mkdir(parents=True, exist_ok=True)
+            thumbnail_actual_path.write_bytes(b"thumbnail")
+
+        async with session_maker() as db:
+            printer = Printer(
+                name=f"Printer {case_counter}",
+                serial_number=f"SERIAL-{case_counter}",
+                ip_address="127.0.0.1",
+                access_code="access-code",
+                model="X1C",
+            )
+            library_file = LibraryFile(
+                filename=f"source-{case_counter}.3mf",
+                file_path=str(source_path),
+                file_type="3mf",
+                file_size=source_path.stat().st_size,
+                file_hash=None,
+                thumbnail_path=thumbnail_db_path,
+                file_metadata=None,
+                is_external=is_external,
+            )
+            db.add_all([printer, library_file])
+            await db.flush()
+
+            item = PrintQueueItem(
+                printer_id=printer.id,
+                library_file_id=library_file.id,
+                status="pending",
+                cleanup_library_after_dispatch=cleanup,
+                bed_levelling=True,
+                flow_cali=False,
+                vibration_cali=True,
+                layer_inspect=False,
+                timelapse=False,
+                use_ams=True,
+                nozzle_offset_cali=True,
+            )
+            db.add(item)
+            await db.commit()
+
+            return SimpleNamespace(
+                session_maker=session_maker,
+                base_dir=base_dir,
+                source_path=source_path,
+                thumbnail_path=thumbnail_actual_path,
+                printer_id=printer.id,
+                library_file_id=library_file.id,
+                queue_item_id=item.id,
+                archive_path=None,
+                upload=AsyncMock(return_value=True),
+                start_print=MagicMock(return_value=True),
+            )
+
+    try:
+        yield make_case
+    finally:
+        await engine.dispose()
+
+
+async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effect=None):
+    scheduler = PrintScheduler()
+
+    async def archive_print(self, *, printer_id, source_file, original_filename, created_by_id=None, project_id=None):
+        if archive_failure:
+            raise RuntimeError("archive copy failed")
+
+        archive_rel_path = Path("archives") / f"archive-{ctx.queue_item_id}.3mf"
+        ctx.archive_path = ctx.base_dir / archive_rel_path
+        ctx.archive_path.parent.mkdir(parents=True, exist_ok=True)
+        ctx.archive_path.write_bytes(Path(source_file).read_bytes())
+
+        archive = PrintArchive(
+            printer_id=printer_id,
+            filename=original_filename,
+            file_path=str(archive_rel_path),
+            file_size=ctx.archive_path.stat().st_size,
+            content_hash=None,
+            thumbnail_path=None,
+            timelapse_path=None,
+            print_time_seconds=120,
+            status="completed",
+            project_id=project_id,
+            created_by_id=created_by_id,
+        )
+        self.db.add(archive)
+        await self.db.flush()
+        return archive
+
+    patches = [
+        patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
+        patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager.start_print", ctx.start_print),
+        patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
+        patch(
+            "backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
+        ),
+        patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
+        patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
+        patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
+        patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
+        patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
+        patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
+        patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
+        patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
+    ]
+    if unlink_side_effect:
+        patches.append(patch.object(type(ctx.source_path), "unlink", unlink_side_effect))
+
+    with ExitStack() as stack:
+        for patcher in patches:
+            stack.enter_context(patcher)
+
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, ctx.queue_item_id)
+            await scheduler._start_print(db, item)
+
+
+async def _queue_snapshot(ctx):
+    async with ctx.session_maker() as db:
+        item = await db.get(PrintQueueItem, ctx.queue_item_id)
+        library_file = await db.get(LibraryFile, ctx.library_file_id)
+        archive = await db.get(PrintArchive, item.archive_id) if item.archive_id else None
+        return item, library_file, archive
+
+
+@pytest.mark.asyncio
+async def test_cleanup_unlinks_library_file_and_removes_db_row(queue_factory):
+    ctx = await queue_factory(cleanup=True)
+
+    await _dispatch_library_item(ctx)
+
+    item, library_file, archive = await _queue_snapshot(ctx)
+    assert item.status == "printing"
+    assert item.library_file_id is None
+    assert item.archive_id == archive.id
+    assert library_file is None
+    assert not ctx.source_path.exists()
+
+
+@pytest.mark.asyncio
+async def test_external_library_file_skips_cleanup(queue_factory):
+    ctx = await queue_factory(cleanup=True, is_external=True)
+
+    await _dispatch_library_item(ctx)
+
+    item, library_file, archive = await _queue_snapshot(ctx)
+    assert item.status == "printing"
+    assert item.library_file_id == ctx.library_file_id
+    assert item.archive_id == archive.id
+    assert library_file is not None
+    assert ctx.source_path.exists()
+
+
+@pytest.mark.asyncio
+async def test_archive_creation_failure_skips_cleanup_and_dispatch(queue_factory):
+    ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
+
+    await _dispatch_library_item(ctx, archive_failure=True)
+
+    item, library_file, archive = await _queue_snapshot(ctx)
+    assert item.status == "failed"
+    assert item.error_message == "Failed to create archive from library file"
+    assert item.archive_id is None
+    assert archive is None
+    assert library_file is not None
+    assert ctx.source_path.exists()
+    assert ctx.thumbnail_path.exists()
+    ctx.upload.assert_not_awaited()
+    ctx.start_print.assert_not_called()
+
+
+@pytest.mark.parametrize("thumbnail_path", ["absolute", "relative"])
+@pytest.mark.asyncio
+async def test_cleanup_resolves_absolute_and_relative_thumbnail_paths(queue_factory, thumbnail_path):
+    ctx = await queue_factory(cleanup=True, thumbnail_path=thumbnail_path)
+
+    await _dispatch_library_item(ctx)
+
+    item, library_file, archive = await _queue_snapshot(ctx)
+    assert item.status == "printing"
+    assert item.archive_id == archive.id
+    assert library_file is None
+    assert not ctx.source_path.exists()
+    assert not ctx.thumbnail_path.exists()
+
+
+@pytest.mark.asyncio
+async def test_archive_copy_survives_library_cleanup(queue_factory):
+    ctx = await queue_factory(cleanup=True)
+
+    await _dispatch_library_item(ctx)
+
+    assert not ctx.source_path.exists()
+    assert ctx.archive_path.exists()
+    assert ctx.archive_path.read_bytes() == b"library source"
+    uploaded_path = ctx.upload.await_args.args[2]
+    assert uploaded_path == ctx.archive_path
+
+
+@pytest.mark.asyncio
+async def test_oserror_during_unlink_logs_orphan_path_and_does_not_crash_dispatch(queue_factory, caplog):
+    ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
+    original_unlink = type(ctx.source_path).unlink
+
+    def unlink_with_source_failure(path, *args, **kwargs):
+        if Path(path) == ctx.source_path:
+            raise OSError("permission denied")
+        return original_unlink(path, *args, **kwargs)
+
+    with caplog.at_level("WARNING", logger="backend.app.services.print_scheduler"):
+        await _dispatch_library_item(ctx, unlink_side_effect=unlink_with_source_failure)
+
+    item, library_file, archive = await _queue_snapshot(ctx)
+    assert item.status == "printing"
+    assert item.archive_id == archive.id
+    assert item.library_file_id is None
+    assert library_file is None
+    assert ctx.source_path.exists()
+    assert not ctx.thumbnail_path.exists()
+    assert ctx.archive_path.exists()
+    assert "TRANSIENT_LIBRARY_FILE_ORPHAN" in caplog.text
+    assert str(ctx.source_path) in caplog.text
+    assert "permission denied" in caplog.text

+ 187 - 123
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -1,9 +1,8 @@
 /**
  * Tests for the unified PrintModal component.
  *
- * The PrintModal supports three modes:
- * - 'reprint': Immediate print from archive (multi-printer support)
- * - 'add-to-queue': Schedule print to queue (multi-printer support)
+ * The PrintModal supports two modes:
+ * - 'create': Create a print queue item
  * - 'edit-queue-item': Edit existing queue item (single printer)
  */
 
@@ -73,9 +72,6 @@ describe('PrintModal', () => {
       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({ success: true });
-      }),
       http.post('/api/v1/queue/', () => {
         return HttpResponse.json({ id: 1, status: 'pending' });
       }),
@@ -85,25 +81,26 @@ describe('PrintModal', () => {
     );
   });
 
-  describe('reprint mode', () => {
+  describe('create mode', () => {
     it('renders the modal title', () => {
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
           onClose={mockOnClose}
           onSuccess={mockOnSuccess}
         />
       );
 
-      expect(screen.getByText('Re-print')).toBeInTheDocument();
+      expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument();
     });
 
     it('shows archive name', () => {
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -117,7 +114,7 @@ describe('PrintModal', () => {
     it('shows printer selection with checkboxes for multi-select', async () => {
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -134,7 +131,7 @@ describe('PrintModal', () => {
     it('has print button', () => {
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -150,7 +147,7 @@ describe('PrintModal', () => {
     it('has cancel button', () => {
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -165,7 +162,7 @@ describe('PrintModal', () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -181,7 +178,7 @@ describe('PrintModal', () => {
     it('print button is disabled until printer is selected', () => {
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -203,7 +200,7 @@ describe('PrintModal', () => {
 
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -216,39 +213,42 @@ describe('PrintModal', () => {
       });
     });
 
-    it('shows print options toggle', () => {
+    it('shows print options toggle', async () => {
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
           onClose={mockOnClose}
           onSuccess={mockOnSuccess}
         />
       );
 
-      expect(screen.getByText('Print Options')).toBeInTheDocument();
+      await waitFor(() => {
+        expect(screen.getByText('Print Options')).toBeInTheDocument();
+      });
     });
   });
 
-  describe('add-to-queue mode', () => {
+  describe('create mode', () => {
     it('renders the modal title', () => {
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
         />
       );
 
-      expect(screen.getByText('Schedule Print')).toBeInTheDocument();
+      expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument();
     });
 
     it('shows archive name', () => {
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
@@ -261,20 +261,20 @@ describe('PrintModal', () => {
     it('shows add button', () => {
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
         />
       );
 
-      expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
     });
 
     it('shows cancel button', () => {
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
@@ -284,23 +284,23 @@ describe('PrintModal', () => {
       expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
     });
 
-    it('shows Queue Only option', () => {
+    it('shows Queue option', () => {
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
         />
       );
 
-      expect(screen.getByText('Queue Only')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();
     });
 
     it('shows power off option', () => {
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
@@ -313,22 +313,37 @@ describe('PrintModal', () => {
     it('shows schedule options', () => {
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByRole('button', { name: /asap/i })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /schedule/i })).toBeInTheDocument();
+    });
+
+    it('orders schedule options by time', () => {
+      render(
+        <PrintModal
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
         />
       );
 
-      expect(screen.getByText('ASAP')).toBeInTheDocument();
-      expect(screen.getByText('Scheduled')).toBeInTheDocument();
+      const options = screen.getAllByRole('button', { name: /^(asap|queue|schedule)$/i });
+      expect(options.map(button => button.textContent?.trim())).toEqual(['ASAP', 'Queue', 'Schedule']);
     });
 
     it('calls onClose when cancel is clicked', async () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
@@ -406,7 +421,7 @@ describe('PrintModal', () => {
       expect(screen.getByText('Print Options')).toBeInTheDocument();
     });
 
-    it('shows Queue Only option', () => {
+    it('shows Queue option', () => {
       const item = createMockQueueItem();
 
       render(
@@ -419,7 +434,7 @@ describe('PrintModal', () => {
         />
       );
 
-      expect(screen.getByText('Queue Only')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();
     });
 
     it('shows power off option', () => {
@@ -482,7 +497,7 @@ describe('PrintModal', () => {
     it('shows select all button when multiple printers available', async () => {
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -498,7 +513,7 @@ describe('PrintModal', () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -520,7 +535,7 @@ describe('PrintModal', () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -534,7 +549,7 @@ describe('PrintModal', () => {
       await user.click(screen.getByText('Select all'));
 
       await waitFor(() => {
-        expect(screen.getByRole('button', { name: /print to 3 printers/i })).toBeInTheDocument();
+        expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
       });
     });
   });
@@ -566,10 +581,10 @@ describe('PrintModal', () => {
       );
     });
 
-    it('shows state badges on printers in reprint mode', async () => {
+    it('shows state badges on printers in create mode', async () => {
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -583,11 +598,11 @@ describe('PrintModal', () => {
       });
     });
 
-    it('prevents selecting a busy printer in reprint mode', async () => {
+    it('allows selecting a busy printer in create mode', async () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -598,28 +613,20 @@ describe('PrintModal', () => {
         expect(screen.getByText('Printing')).toBeInTheDocument();
       });
 
-      // The busy printer button should be disabled
       const busyButton = screen.getByText('X1 Carbon').closest('button');
-      expect(busyButton).toBeDisabled();
-
-      // Click the busy printer — selection should not change
+      expect(busyButton).not.toBeDisabled();
       await user.click(busyButton!);
 
-      // Idle printer should still be selectable
-      const idleButton = screen.getByText('P1S').closest('button');
-      expect(idleButton).not.toBeDisabled();
-      await user.click(idleButton!);
-
       await waitFor(() => {
         expect(screen.getByText('1 printer selected')).toBeInTheDocument();
       });
     });
 
-    it('select all skips busy printers in reprint mode', async () => {
+    it('select all includes busy printers in create mode', async () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -634,16 +641,15 @@ describe('PrintModal', () => {
       await user.click(screen.getByText('Select all'));
 
       await waitFor(() => {
-        // Only 2 available printers selected (IDLE + FINISH), not the RUNNING one
-        expect(screen.getByText(/2 printers selected/)).toBeInTheDocument();
+        expect(screen.getByText(/3 printers selected/)).toBeInTheDocument();
       });
     });
 
-    it('allows selecting busy printers in add-to-queue mode', async () => {
+    it('allows selecting busy printers in create mode', async () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -677,7 +683,7 @@ describe('PrintModal', () => {
 
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -702,7 +708,7 @@ describe('PrintModal', () => {
 
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -721,7 +727,7 @@ describe('PrintModal', () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
@@ -742,7 +748,7 @@ describe('PrintModal', () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
@@ -760,11 +766,11 @@ describe('PrintModal', () => {
       });
     });
 
-    it('shows stagger option in reprint mode with multiple printers', async () => {
+    it('shows stagger option in create mode with multiple printers', async () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
@@ -784,11 +790,11 @@ describe('PrintModal', () => {
       expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
     });
 
-    it('shows stagger preview in reprint mode when enabled', async () => {
+    it('shows stagger preview in create mode when enabled', async () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
@@ -813,11 +819,11 @@ describe('PrintModal', () => {
       });
     });
 
-    it('does not show stagger option in reprint mode with single printer', async () => {
+    it('does not show stagger option in create mode with single printer', async () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
@@ -838,7 +844,7 @@ describe('PrintModal', () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
@@ -867,7 +873,7 @@ describe('PrintModal', () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Test Print"
           onClose={mockOnClose}
@@ -911,10 +917,10 @@ describe('PrintModal', () => {
       );
     });
 
-    it('shows "Select All" button only in add-to-queue mode', async () => {
+    it('shows "Select All" button only in create mode', async () => {
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="MultiPlate.3mf"
           onClose={mockOnClose}
@@ -926,10 +932,10 @@ describe('PrintModal', () => {
       });
     });
 
-    it('does not show "Select All" button in reprint mode', async () => {
+    it('shows "Select All" button in create mode', async () => {
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="MultiPlate.3mf"
           initialSelectedPrinterIds={[1]}
@@ -940,14 +946,14 @@ describe('PrintModal', () => {
       await waitFor(() => {
         expect(screen.getByText('Plate 1')).toBeInTheDocument();
       });
-      expect(screen.queryByText('Select All 3 Plates')).not.toBeInTheDocument();
+      expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
     });
 
     it('selects all plates when "Select All" is clicked', async () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="MultiPlate.3mf"
           onClose={mockOnClose}
@@ -981,7 +987,7 @@ describe('PrintModal', () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="MultiPlate.3mf"
           onClose={mockOnClose}
@@ -998,7 +1004,7 @@ describe('PrintModal', () => {
       // Select printer
       await user.click(screen.getByText('X1 Carbon'));
 
-      // Plate 1 is auto-selected. Click Plate 3 to add it (multi-select in add-to-queue mode)
+      // Plate 1 is auto-selected. Click Plate 3 to add it (multi-select in create mode)
       await user.click(screen.getByText('Plate 3'));
 
       // Submit — should queue plates 1 and 3
@@ -1026,7 +1032,7 @@ describe('PrintModal', () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="MultiPlate.3mf"
           onClose={mockOnClose}
@@ -1062,10 +1068,10 @@ describe('PrintModal', () => {
   });
 
   describe('batch quantity', () => {
-    it('shows quantity input in reprint mode', () => {
+    it('shows quantity input in create mode', () => {
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -1076,10 +1082,10 @@ describe('PrintModal', () => {
       expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
     });
 
-    it('shows quantity input in add-to-queue mode', () => {
+    it('shows quantity input in create mode', () => {
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -1108,7 +1114,7 @@ describe('PrintModal', () => {
     it('defaults quantity to 1', () => {
       render(
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           onClose={mockOnClose}
@@ -1124,7 +1130,7 @@ describe('PrintModal', () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           initialSelectedPrinterIds={[1]}
@@ -1143,10 +1149,10 @@ describe('PrintModal', () => {
   });
 
   describe('reprint G-code injection dispatch (#422 / auto-eject)', () => {
-    // Guards the fix: when "Inject auto-print G-code" is ticked on a reprint with
+    // Guards the fix: when "Inject auto-print G-code" is ticked on a create with
     // quantity > 1, ALL copies must go through the queue so every one is injected by
     // the scheduler. The first copy must NOT be dispatched immediately via the direct
-    // reprint path — that path bypasses injection and would leave the first copy stuck
+    // create path — that path bypasses injection and would leave the first copy stuck
     // on the plate for auto-eject setups.
     const withSnippets = () =>
       http.get('/api/v1/settings/', () =>
@@ -1154,14 +1160,9 @@ describe('PrintModal', () => {
       );
 
     it('injection ON queues all copies and dispatches none immediately', async () => {
-      const reprintCalls: unknown[] = [];
       const queueCalls: Record<string, unknown>[] = [];
       server.use(
         withSnippets(),
-        http.post('/api/v1/archives/:id/reprint', async ({ request }) => {
-          reprintCalls.push(await request.json().catch(() => ({})));
-          return HttpResponse.json({ status: 'dispatched' });
-        }),
         http.post('/api/v1/queue/', async ({ request }) => {
           queueCalls.push((await request.json()) as Record<string, unknown>);
           return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
@@ -1171,7 +1172,7 @@ describe('PrintModal', () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           initialSelectedPrinterIds={[1]}
@@ -1194,18 +1195,12 @@ describe('PrintModal', () => {
       await waitFor(() => expect(queueCalls.length).toBe(1));
       // One queue item carrying all copies, and zero immediate reprint dispatches
       expect(queueCalls[0].quantity).toBe(3);
-      expect(reprintCalls.length).toBe(0);
     });
 
-    it('injection OFF keeps the immediate first copy and queues the rest', async () => {
-      const reprintCalls: unknown[] = [];
+    it('injection OFF queues all copies through the scheduler path', async () => {
       const queueCalls: Record<string, unknown>[] = [];
       server.use(
         withSnippets(),
-        http.post('/api/v1/archives/:id/reprint', async ({ request }) => {
-          reprintCalls.push(await request.json().catch(() => ({})));
-          return HttpResponse.json({ status: 'dispatched' });
-        }),
         http.post('/api/v1/queue/', async ({ request }) => {
           queueCalls.push((await request.json()) as Record<string, unknown>);
           return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
@@ -1215,7 +1210,7 @@ describe('PrintModal', () => {
       const user = userEvent.setup();
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           initialSelectedPrinterIds={[1]}
@@ -1229,12 +1224,11 @@ describe('PrintModal', () => {
       await user.keyboard('3');
       expect(qty.value).toBe('3');
 
-      // Leave injection unticked → first copy prints immediately, rest queue
+      // Leave injection unticked: unified dispatch still queues all copies.
       await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
 
-      await waitFor(() => expect(reprintCalls.length).toBe(1));
-      expect(queueCalls.length).toBe(1);
-      expect(queueCalls[0].quantity).toBe(2);
+      await waitFor(() => expect(queueCalls.length).toBe(1));
+      expect(queueCalls[0].quantity).toBe(3);
     });
   });
 
@@ -1269,19 +1263,19 @@ describe('PrintModal', () => {
       );
     });
 
-    it('includes project_id in printLibraryFile call when projectId prop is set', async () => {
+    it('includes project_id in queue item when printing a library file with projectId set', async () => {
       let capturedBody: Record<string, unknown> | null = null;
       server.use(
-        http.post('/api/v1/library/files/:id/print', async ({ request }) => {
+        http.post('/api/v1/queue/', async ({ request }) => {
           capturedBody = await request.json() as Record<string, unknown>;
-          return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });
+          return HttpResponse.json({ id: 1, status: 'pending' });
         })
       );
       const user = userEvent.setup();
 
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           libraryFileId={5}
           archiveName="Benchy"
           projectId={42}
@@ -1300,25 +1294,24 @@ describe('PrintModal', () => {
 
       await waitFor(() => {
         expect(capturedBody).not.toBeNull();
+        expect(capturedBody?.library_file_id).toBe(5);
         expect(capturedBody?.project_id).toBe(42);
       });
     });
 
-    it('does NOT include project_id in reprintArchive call (archives carry their own project association)', async () => {
-      // The reprintArchive branch omits project_id by design — archives already carry
-      // their project association from the original print. This test guards that intent.
+    it('queues archive prints through the scheduler path', async () => {
       let capturedBody: Record<string, unknown> | null = null;
       server.use(
-        http.post('/api/v1/archives/:id/reprint', async ({ request }) => {
+        http.post('/api/v1/queue/', async ({ request }) => {
           capturedBody = await request.json() as Record<string, unknown>;
-          return HttpResponse.json({ status: 'dispatched' });
+          return HttpResponse.json({ id: 1, status: 'pending' });
         })
       );
       const user = userEvent.setup();
 
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={1}
           archiveName="Benchy"
           projectId={42}
@@ -1336,7 +1329,78 @@ describe('PrintModal', () => {
 
       await waitFor(() => {
         expect(capturedBody).not.toBeNull();
-        expect(capturedBody).not.toHaveProperty('project_id');
+        expect(capturedBody?.archive_id).toBe(1);
+        expect(capturedBody?.project_id).toBe(42);
+      });
+    });
+
+    it('adds ASAP prints to the top of the queue', async () => {
+      let capturedBody: Record<string, unknown> | null = null;
+      server.use(
+        http.post('/api/v1/queue/', async ({ request }) => {
+          capturedBody = await request.json() as Record<string, unknown>;
+          return HttpResponse.json({ id: 1, status: 'pending' });
+        })
+      );
+      const user = userEvent.setup();
+
+      render(
+        <PrintModal
+          mode="create"
+          archiveId={1}
+          archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByRole('button', { name: /^print$/i }));
+
+      await waitFor(() => {
+        expect(capturedBody).not.toBeNull();
+        expect(capturedBody?.insert_at_top).toBe(true);
+        expect(capturedBody?.insert_position).toBe(1);
+        expect(capturedBody?.manual_start).toBe(false);
+        expect(capturedBody?.scheduled_time).toBeUndefined();
+      });
+    });
+
+    it('adds Queue prints to the back unless manual start is required', async () => {
+      let capturedBody: Record<string, unknown> | null = null;
+      server.use(
+        http.post('/api/v1/queue/', async ({ request }) => {
+          capturedBody = await request.json() as Record<string, unknown>;
+          return HttpResponse.json({ id: 1, status: 'pending' });
+        })
+      );
+      const user = userEvent.setup();
+
+      render(
+        <PrintModal
+          mode="create"
+          archiveId={1}
+          archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      await user.click(screen.getByRole('button', { name: /^queue$/i }));
+      expect(screen.getByLabelText(/require manual start/i)).toBeInTheDocument();
+      await user.click(screen.getByLabelText(/require manual start/i));
+      await user.click(screen.getByRole('button', { name: /^print$/i }));
+
+      await waitFor(() => {
+        expect(capturedBody).not.toBeNull();
+        expect(capturedBody?.insert_at_top).toBeUndefined();
+        expect(capturedBody?.insert_position).toBeUndefined();
+        expect(capturedBody?.manual_start).toBe(true);
       });
     });
   });
@@ -1377,16 +1441,16 @@ describe('PrintModal', () => {
     it('forwards cleanup_library_after_dispatch=true when the Direct-Print prop is set', async () => {
       let capturedBody: Record<string, unknown> | null = null;
       server.use(
-        http.post('/api/v1/library/files/:id/print', async ({ request }) => {
+        http.post('/api/v1/queue/', async ({ request }) => {
           capturedBody = (await request.json()) as Record<string, unknown>;
-          return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });
+          return HttpResponse.json({ id: 1, status: 'pending' });
         })
       );
       const user = userEvent.setup();
 
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           libraryFileId={5}
           archiveName="Benchy"
           cleanupLibraryAfterDispatch
@@ -1411,16 +1475,16 @@ describe('PrintModal', () => {
     it('defaults to omitting cleanup_library_after_dispatch (File Manager / Project flows survive)', async () => {
       let capturedBody: Record<string, unknown> | null = null;
       server.use(
-        http.post('/api/v1/library/files/:id/print', async ({ request }) => {
+        http.post('/api/v1/queue/', async ({ request }) => {
           capturedBody = (await request.json()) as Record<string, unknown>;
-          return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });
+          return HttpResponse.json({ id: 1, status: 'pending' });
         })
       );
       const user = userEvent.setup();
 
       render(
         <PrintModal
-          mode="reprint"
+          mode="create"
           libraryFileId={5}
           archiveName="Benchy"
           initialSelectedPrinterIds={[1]}

+ 115 - 8
frontend/src/__tests__/components/PrintModalDispatchToast.test.tsx

@@ -1,6 +1,5 @@
 /**
- * Test that reprint mode does not show the "Print queued for printer" toast.
- * The background dispatch websocket toast handles feedback instead.
+ * Test that create mode now goes through the queue-backed create path.
  *
  * Separate file because vi.mock(ToastContext) must be module-scoped
  * and would interfere with the main PrintModal test suite.
@@ -48,17 +47,17 @@ describe('PrintModal dispatch toast', () => {
       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 });
+      http.post('/api/v1/queue/', () => {
+        return HttpResponse.json({ id: 1, status: 'pending' });
       }),
     );
   });
 
-  it('does not show "queued" toast in reprint mode (dispatch toast handles it)', async () => {
+  it('shows queued toast in create mode', async () => {
     const user = userEvent.setup();
     render(
       <PrintModal
-        mode="reprint"
+        mode="create"
         archiveId={1}
         archiveName="Benchy"
         onClose={mockOnClose}
@@ -81,8 +80,116 @@ describe('PrintModal dispatch toast', () => {
       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');
+    expect(toastMessages).toContain('Print queued');
+  });
+
+  it('uses wait-for-idle copy when an ASAP target is offline', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => {
+        return HttpResponse.json({ connected: false, state: null, ams: [], vt_tray: [] });
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(
+      <PrintModal
+        mode="create"
+        archiveId={1}
+        archiveName="Benchy"
+        initialSelectedPrinterIds={[1]}
+        onClose={mockOnClose}
+        onSuccess={mockOnSuccess}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
+    });
+    await user.click(screen.getByRole('button', { name: /^print$/i }));
+
+    await waitFor(() => {
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+
+    const toastMessages = mockShowToast.mock.calls.map(call => call[0]);
+    expect(toastMessages).toContain('Will start when printer is idle');
+    expect(toastMessages).not.toContain('Print queued');
+  });
+
+  it('uses wait-for-idle copy when an ASAP target is held for plate clear', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => {
+        return HttpResponse.json({
+          connected: true,
+          state: 'FINISH',
+          awaiting_plate_clear: true,
+          ams: [],
+          vt_tray: [],
+        });
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(
+      <PrintModal
+        mode="create"
+        archiveId={1}
+        archiveName="Benchy"
+        initialSelectedPrinterIds={[1]}
+        onClose={mockOnClose}
+        onSuccess={mockOnSuccess}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
+    });
+    await user.click(screen.getByRole('button', { name: /^print$/i }));
+
+    await waitFor(() => {
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+
+    const toastMessages = mockShowToast.mock.calls.map(call => call[0]);
+    expect(toastMessages).toContain('Will start when printer is idle');
+  });
+
+  it('uses wait-for-idle copy when an ASAP target is drying filament', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => {
+        return HttpResponse.json({
+          connected: true,
+          state: 'IDLE',
+          awaiting_plate_clear: false,
+          ams: [{ id: 0, dry_time: 25, tray: [] }],
+          vt_tray: [],
+        });
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(
+      <PrintModal
+        mode="create"
+        archiveId={1}
+        archiveName="Benchy"
+        initialSelectedPrinterIds={[1]}
+        onClose={mockOnClose}
+        onSuccess={mockOnSuccess}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
+    });
+    await user.click(screen.getByRole('button', { name: /^print$/i }));
+
+    await waitFor(() => {
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+
+    const toastMessages = mockShowToast.mock.calls.map(call => call[0]);
+    expect(toastMessages).toContain('Will start when printer is idle');
   });
 });

+ 2 - 82
frontend/src/__tests__/contexts/ToastContext.test.tsx

@@ -95,90 +95,10 @@ describe('ToastContext post-unmount safety', () => {
   });
 });
 
-describe('ToastContext background dispatch — upload-done UX', () => {
-  // Small fast files reach 100% upload before the printer's MQTT confirmation
-  // arrives, leaving the bar parked at 100% for what feels like "stuck". When
-  // status is still 'processing' but uploadProgressPct >= 99.9 the byte-count
-  // line should switch to "Awaiting printer..." and the bar gets a pulse.
-  function dispatchBackgroundEvent(detail: Record<string, unknown>) {
-    window.dispatchEvent(new CustomEvent('background-dispatch', { detail }));
-  }
-
-  it('shows "Awaiting printer..." once upload is complete but printer has not confirmed', () => {
-    const { container } = render(
-      <ToastProvider>
-        <div />
-      </ToastProvider>
-    );
-
-    act(() => {
-      dispatchBackgroundEvent({
-        total: 1,
-        dispatched: 0,
-        processing: 1,
-        completed: 0,
-        failed: 0,
-        active_jobs: [
-          {
-            job_id: 42,
-            printer_name: 'X1C-2',
-            source_name: 'Benchy.3mf',
-            upload_bytes: 102400,
-            upload_total_bytes: 102400,
-            upload_progress_pct: 100.0,
-          },
-        ],
-      });
-    });
-
-    // The byte-count line should be replaced with the awaiting-printer text.
-    expect(container.textContent).toContain('Awaiting printer');
-    // And the original bytes-progressed format must not be visible at the
-    // same time — that is the "stuck at 100%" symptom we are fixing.
-    expect(container.textContent).not.toContain('100.0%');
-
-    // Bar gets the pulse class when in this state.
-    const bar = container.querySelector('.animate-pulse');
-    expect(bar).not.toBeNull();
-  });
-
-  it('still shows the byte/percent counter while upload is mid-flight', () => {
-    const { container } = render(
-      <ToastProvider>
-        <div />
-      </ToastProvider>
-    );
-
-    act(() => {
-      dispatchBackgroundEvent({
-        total: 1,
-        dispatched: 0,
-        processing: 1,
-        completed: 0,
-        failed: 0,
-        active_jobs: [
-          {
-            job_id: 7,
-            printer_name: 'X1C-2',
-            source_name: 'Benchy.3mf',
-            upload_bytes: 51200,
-            upload_total_bytes: 102400,
-            upload_progress_pct: 50.0,
-          },
-        ],
-      });
-    });
-
-    expect(container.textContent).toContain('50.0%');
-    expect(container.textContent).not.toContain('Awaiting printer');
-    expect(container.querySelector('.animate-pulse')).toBeNull();
-  });
-});
-
 describe('ToastContext viewport suppression', () => {
   // The kiosk layout flips setViewportSuppressed(true) on mount so the
-  // SpoolBuddy display stays free of main-app toasts (background dispatch
-  // progress, login flows, etc.). Verify the gate hides the visible viewport
+  // SpoolBuddy display stays free of main-app toasts (login flows, etc.).
+  // Verify the gate hides the visible viewport
   // without affecting the underlying state machine.
   function ViewportProbe() {
     const { showToast, setViewportSuppressed } = useToast();

+ 0 - 105
frontend/src/__tests__/hooks/useDispatchedPrinterIds.test.ts

@@ -1,105 +0,0 @@
-/**
- * Tests for useDispatchedPrinterIds — the hook that exposes printer IDs with
- * a queued/active background-dispatch job so PrinterSelector can grey them
- * out between dispatch-accepted and the printer's PRINT_START report.
- */
-
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { renderHook, act } from '@testing-library/react';
-import {
-  useDispatchedPrinterIds,
-  __resetDispatchedPrinterIdsForTests,
-} from '../../hooks/useDispatchedPrinterIds';
-
-function fire(detail: Record<string, unknown>) {
-  act(() => {
-    window.dispatchEvent(new CustomEvent('background-dispatch', { detail }));
-  });
-}
-
-describe('useDispatchedPrinterIds', () => {
-  beforeEach(() => {
-    __resetDispatchedPrinterIdsForTests();
-  });
-
-  afterEach(() => {
-    __resetDispatchedPrinterIdsForTests();
-  });
-
-  it('returns an empty set initially', () => {
-    const { result } = renderHook(() => useDispatchedPrinterIds());
-    expect(result.current.size).toBe(0);
-  });
-
-  it('picks up printer IDs from dispatched_jobs', () => {
-    const { result } = renderHook(() => useDispatchedPrinterIds());
-    fire({
-      dispatched_jobs: [
-        { job_id: 1, printer_id: 42, printer_name: 'Farm-A' },
-      ],
-      active_jobs: [],
-    });
-    expect(result.current.has(42)).toBe(true);
-    expect(result.current.size).toBe(1);
-  });
-
-  it('picks up printer IDs from active_jobs', () => {
-    const { result } = renderHook(() => useDispatchedPrinterIds());
-    fire({
-      dispatched_jobs: [],
-      active_jobs: [
-        { job_id: 1, printer_id: 7, printer_name: 'Farm-B' },
-      ],
-    });
-    expect(result.current.has(7)).toBe(true);
-  });
-
-  it('unions both lists', () => {
-    const { result } = renderHook(() => useDispatchedPrinterIds());
-    fire({
-      dispatched_jobs: [{ job_id: 1, printer_id: 1 }],
-      active_jobs: [{ job_id: 2, printer_id: 2 }],
-    });
-    expect(result.current.size).toBe(2);
-    expect(result.current.has(1)).toBe(true);
-    expect(result.current.has(2)).toBe(true);
-  });
-
-  it('clears printers when subsequent event reports no jobs', () => {
-    const { result } = renderHook(() => useDispatchedPrinterIds());
-    fire({ dispatched_jobs: [{ job_id: 1, printer_id: 9 }], active_jobs: [] });
-    expect(result.current.has(9)).toBe(true);
-    fire({ dispatched_jobs: [], active_jobs: [] });
-    expect(result.current.size).toBe(0);
-  });
-
-  it('ignores jobs without a numeric printer_id', () => {
-    const { result } = renderHook(() => useDispatchedPrinterIds());
-    fire({
-      dispatched_jobs: [
-        { job_id: 1, printer_id: 'not-a-number' },
-        { job_id: 2 },
-        { job_id: 3, printer_id: 5 },
-      ],
-      active_jobs: [],
-    });
-    expect(result.current.size).toBe(1);
-    expect(result.current.has(5)).toBe(true);
-  });
-
-  it('keeps snapshot reference stable when content is unchanged', () => {
-    const { result } = renderHook(() => useDispatchedPrinterIds());
-    fire({ dispatched_jobs: [{ printer_id: 1 }], active_jobs: [] });
-    const first = result.current;
-    fire({ dispatched_jobs: [{ printer_id: 1 }], active_jobs: [] });
-    expect(result.current).toBe(first);
-  });
-
-  it('shares state across hook instances', () => {
-    const a = renderHook(() => useDispatchedPrinterIds());
-    const b = renderHook(() => useDispatchedPrinterIds());
-    fire({ dispatched_jobs: [{ printer_id: 11 }], active_jobs: [] });
-    expect(a.result.current.has(11)).toBe(true);
-    expect(b.result.current.has(11)).toBe(true);
-  });
-});

+ 12 - 6
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -473,8 +473,15 @@ describe('FileManagerPage', () => {
     });
   });
 
-  describe('schedule print', () => {
-    it('shows schedule print button when one sliced file is selected', async () => {
+  describe('bulk-action print button', () => {
+    // PR #1625 consolidated print actions: the old single-file-selected
+    // "Schedule" button now opens the unified PrintModal (which carries
+    // schedule options inside). The bulk-action toolbar shows a single
+    // "Print" button only when exactly one sliced file is selected, and
+    // hides it for multi-selection. The button is targeted by its accessible
+    // name ("Print") + role to disambiguate from the file-card dropdown's
+    // own Print entry, which stays collapsed unless its kebab is opened.
+    it('shows a Print button in the bulk toolbar when one sliced file is selected', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
 
@@ -489,11 +496,11 @@ describe('FileManagerPage', () => {
       }
 
       await waitFor(() => {
-        expect(screen.getByText(/Schedule/)).toBeInTheDocument();
+        expect(screen.getByRole('button', { name: /^Print$/ })).toBeInTheDocument();
       });
     });
 
-    it('hides schedule print button when multiple files are selected', async () => {
+    it('hides the bulk Print button when multiple files are selected', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
 
@@ -505,8 +512,7 @@ describe('FileManagerPage', () => {
       await user.click(screen.getByText('Select All'));
 
       await waitFor(() => {
-        // Schedule button should not be present when multiple files are selected
-        expect(screen.queryByText(/Schedule/)).not.toBeInTheDocument();
+        expect(screen.queryByRole('button', { name: /^Print$/ })).not.toBeInTheDocument();
       });
     });
   });

+ 6 - 6
frontend/src/__tests__/pages/ProjectDetailPage.test.tsx

@@ -101,7 +101,7 @@ describe('ProjectDetailPage', () => {
       render(<ProjectDetailPage />);
 
       await waitFor(() => {
-        expect(screen.getByTitle('Print Now')).toBeInTheDocument();
+        expect(screen.getByTitle('Print')).toBeInTheDocument();
       });
     });
 
@@ -115,7 +115,7 @@ describe('ProjectDetailPage', () => {
       render(<ProjectDetailPage />);
 
       await waitFor(() => {
-        expect(screen.getByTitle('Print Now')).toBeInTheDocument();
+        expect(screen.getByTitle('Print')).toBeInTheDocument();
       });
     });
 
@@ -132,7 +132,7 @@ describe('ProjectDetailPage', () => {
         expect(screen.getByText('benchy.gcode.bak')).toBeInTheDocument();
       });
 
-      expect(screen.queryByTitle('Print Now')).not.toBeInTheDocument();
+      expect(screen.queryByTitle('Print')).not.toBeInTheDocument();
     });
 
     it('does NOT show print button for .stl files', async () => {
@@ -148,7 +148,7 @@ describe('ProjectDetailPage', () => {
         expect(screen.getByText('model.stl')).toBeInTheDocument();
       });
 
-      expect(screen.queryByTitle('Print Now')).not.toBeInTheDocument();
+      expect(screen.queryByTitle('Print')).not.toBeInTheDocument();
     });
   });
 
@@ -211,10 +211,10 @@ describe('ProjectDetailPage', () => {
       render(<ProjectDetailPage />);
 
       await waitFor(() => {
-        expect(screen.getByTitle('Print Now')).toBeInTheDocument();
+        expect(screen.getByTitle('Print')).toBeInTheDocument();
       });
 
-      await user.click(screen.getByTitle('Print Now'));
+      await user.click(screen.getByTitle('Print'));
 
       // PrintModal should open — look for the modal heading "Print"
       await waitFor(() => {

+ 5 - 68
frontend/src/api/client.ts

@@ -1958,6 +1958,7 @@ export interface PrintQueueItem {
   been_jumped?: boolean;
   // Auto-print G-code injection
   gcode_injection?: boolean;
+  cleanup_library_after_dispatch?: boolean;
 }
 
 export interface PrintBatch {
@@ -1988,6 +1989,8 @@ export interface PrintQueueItemCreate {
   require_previous_success?: boolean;
   auto_off_after?: boolean;
   manual_start?: boolean;  // Requires manual trigger to start (staged)
+  insert_at_top?: boolean;  // Insert ahead of other pending items in the same queue scope
+  insert_position?: number | null;  // 1-indexed insertion position for priority queueing
   // PrintModal "Print Anyway" on the deficit warning — persisted so the
   // scheduler doesn't immediately re-flag this item (#1698-followup).
   skip_filament_check?: boolean;
@@ -2009,6 +2012,8 @@ export interface PrintQueueItemCreate {
   batch_id?: number | null;
   // Project to associate the resulting archive with
   project_id?: number;
+  // Delete transient uploaded library file after scheduler creates the archive
+  cleanup_library_after_dispatch?: boolean;
 }
 
 export interface PrintBatchCreate {
@@ -2476,15 +2481,6 @@ export interface NotificationTestResponse {
   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
 export interface CallMeBotConfig {
   phone: string;
@@ -4381,30 +4377,6 @@ export const api = {
       }>;
     }>(`/archives/${archiveId}/filament-requirements${qs.toString() ? `?${qs}` : ''}`);
   },
-  reprintArchive: (
-    archiveId: number,
-    printerId: number,
-    options?: {
-      plate_id?: number;
-      plate_name?: string;
-      ams_mapping?: number[];
-      timelapse?: boolean;
-      bed_levelling?: boolean;
-      flow_cali?: boolean;
-      vibration_cali?: boolean;
-      layer_inspect?: boolean;
-      use_ams?: boolean;
-      nozzle_offset_cali?: boolean;
-    }
-  ) =>
-    request<BackgroundDispatchResponse>(
-      `/archives/${archiveId}/reprint?printer_id=${printerId}`,
-      {
-        method: 'POST',
-        headers: options ? { 'Content-Type': 'application/json' } : undefined,
-        body: options ? JSON.stringify(options) : undefined,
-      }
-    ),
   uploadArchive: async (file: File, printerId?: number): Promise<Archive> => {
     const formData = new FormData();
     formData.append('file', file);
@@ -6070,41 +6042,6 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ file_ids: fileIds }),
     }),
-  printLibraryFile: (
-    fileId: number,
-    printerId: number,
-    options?: {
-      plate_id?: number;
-      plate_name?: string;
-      ams_mapping?: number[];
-      bed_levelling?: boolean;
-      flow_cali?: boolean;
-      vibration_cali?: boolean;
-      layer_inspect?: boolean;
-      timelapse?: boolean;
-      use_ams?: boolean;
-      nozzle_offset_cali?: boolean;
-      project_id?: number;
-      cleanup_library_after_dispatch?: boolean;
-    }
-  ) =>
-    request<BackgroundDispatchResponse>(
-      `/library/files/${fileId}/print?printer_id=${printerId}`,
-      {
-        method: 'POST',
-        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) =>
     request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
   getLibraryFileFilamentRequirements: (

+ 2 - 2
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -8,8 +8,8 @@ import { getBedTypeInfo } from '../../utils/bedType';
 /**
  * Plate selection grid for multi-plate 3MF files.
  * Shows thumbnails, names, objects, and print times for each plate.
- * In multi-select mode (add-to-queue), plates have checkboxes for selecting a subset.
- * In single-select mode (reprint/edit), only one plate can be selected at a time.
+ * In multi-select mode, plates have checkboxes for selecting a subset.
+ * In single-select mode, only one plate can be selected at a time.
  */
 export function PlateSelector({
   plates,

+ 0 - 9
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -12,7 +12,6 @@ import {
   Users,
 } from 'lucide-react';
 import { api, type PrinterStatus } from '../../api/client';
-import { useDispatchedPrinterIds } from '../../hooks/useDispatchedPrinterIds';
 import { getColorName } from '../../utils/colors';
 import {
   normalizeColorForCompare,
@@ -256,14 +255,7 @@ export function PrinterSelector({
     return map;
   }, [activePrinters, statusQueries]);
 
-  // Printers with a queued/active background dispatch — accepted by Bambuddy
-  // but not yet reflected in PrinterStatus.state (which only flips on
-  // PRINT_START from the printer itself). Backend rejects double-sends with
-  // 409 anyway; this just stops the operator from picking them in the modal.
-  const dispatchedPrinterIds = useDispatchedPrinterIds();
-
   const isPrinterBusy = (printerId: number): boolean => {
-    if (dispatchedPrinterIds.has(printerId)) return true;
     const status = printerStatusMap.get(printerId);
     if (!status) return false; // Unknown state — don't block
     if (!status.connected) return true;
@@ -271,7 +263,6 @@ export function PrinterSelector({
   };
 
   const getPrinterStateLabel = (printerId: number): string | null => {
-    if (dispatchedPrinterIds.has(printerId)) return 'Dispatching...';
     const status = printerStatusMap.get(printerId);
     if (!status) return null;
     if (!status.connected) return 'Offline';

+ 43 - 22
frontend/src/components/PrintModal/ScheduleOptions.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Calendar, Clock, Hand, Power, Layers, Code } from 'lucide-react';
+import { Calendar, Clock, Hand, Power, Layers, Code, ListOrdered } from 'lucide-react';
 import type { ScheduleOptionsProps, ScheduleType } from './types';
 import {
   formatDateInput,
@@ -16,7 +16,7 @@ import {
 
 /**
  * Schedule options component for queue items.
- * Includes schedule type (ASAP/Scheduled/Queue Only), datetime picker,
+ * Includes schedule type (ASAP/Queue/Schedule), datetime picker,
  * and options for require previous success and auto power off.
  */
 export function ScheduleOptionsPanel({
@@ -70,7 +70,11 @@ export function ScheduleOptionsPanel({
   }, [options.scheduleType, options.scheduledTime, dateFormat, timeFormat, onChange, options]);
 
   const handleScheduleTypeChange = (scheduleType: ScheduleType) => {
-    onChange({ ...options, scheduleType });
+    onChange({
+      ...options,
+      scheduleType,
+      requireManualStart: scheduleType === 'queue' ? options.requireManualStart : false,
+    });
   };
 
   const updateScheduledTime = (newDateValue: string, newTimeValue: string) => {
@@ -122,7 +126,7 @@ export function ScheduleOptionsPanel({
     <div className="space-y-4">
       {/* Schedule type */}
       <div>
-        <label className="block text-sm text-bambu-gray mb-2">When to print</label>
+        <label className="block text-sm text-bambu-gray mb-2">{t('printModal.whenToPrint')}</label>
         <div className="flex gap-2">
           <button
             type="button"
@@ -134,31 +138,31 @@ export function ScheduleOptionsPanel({
             onClick={() => handleScheduleTypeChange('asap')}
           >
             <Clock className="w-4 h-4" />
-            ASAP
+            {t('printModal.asap')}
           </button>
           <button
             type="button"
             className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-              options.scheduleType === 'scheduled'
+              options.scheduleType === 'queue'
                 ? 'bg-bambu-green border-bambu-green text-white'
                 : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
             }`}
-            onClick={() => handleScheduleTypeChange('scheduled')}
+            onClick={() => handleScheduleTypeChange('queue')}
           >
-            <Calendar className="w-4 h-4" />
-            Scheduled
+            <ListOrdered className="w-4 h-4" />
+            {t('printModal.queue')}
           </button>
           <button
             type="button"
             className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-              options.scheduleType === 'manual'
+              options.scheduleType === 'scheduled'
                 ? 'bg-bambu-green border-bambu-green text-white'
                 : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
             }`}
-            onClick={() => handleScheduleTypeChange('manual')}
+            onClick={() => handleScheduleTypeChange('scheduled')}
           >
-            <Hand className="w-4 h-4" />
-            Queue Only
+            <Calendar className="w-4 h-4" />
+            {t('printModal.schedule')}
           </button>
         </div>
       </div>
@@ -166,7 +170,7 @@ export function ScheduleOptionsPanel({
       {/* Scheduled time input */}
       {options.scheduleType === 'scheduled' && (
         <div>
-          <label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
+          <label className="block text-sm text-bambu-gray mb-1">{t('printModal.dateTime')}</label>
           <div className="flex gap-2">
             {/* Date input */}
             <div className="flex-1 relative">
@@ -185,7 +189,7 @@ export function ScheduleOptionsPanel({
                 type="button"
                 onClick={openCalendar}
                 className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
-                title="Open calendar"
+                title={t('printModal.openCalendar')}
               >
                 <Calendar className="w-4 h-4" />
               </button>
@@ -216,12 +220,29 @@ export function ScheduleOptionsPanel({
           </div>
           {(!isDateValid || !isTimeValid) && (
             <p className="mt-1 text-xs text-red-400">
-              Please enter a valid date and time
+              {t('printModal.invalidDateTime')}
             </p>
           )}
         </div>
       )}
 
+      {/* Manual start */}
+      {options.scheduleType === 'queue' && (
+        <div className="flex items-center gap-2">
+          <input
+            type="checkbox"
+            id="requireManualStart"
+            checked={options.requireManualStart}
+            onChange={(e) => onChange({ ...options, requireManualStart: e.target.checked })}
+            className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+          />
+          <label htmlFor="requireManualStart" className="text-sm flex items-center gap-1 text-bambu-gray">
+            <Hand className="w-3.5 h-3.5" />
+            {t('printModal.requireManualStart')}
+          </label>
+        </div>
+      )}
+
       {/* Require previous success */}
       <div className="flex items-center gap-2">
         <input
@@ -232,7 +253,7 @@ export function ScheduleOptionsPanel({
           className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
         />
         <label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
-          Only start if previous print succeeded
+          {t('printModal.requirePreviousSuccess')}
         </label>
       </div>
 
@@ -248,7 +269,7 @@ export function ScheduleOptionsPanel({
         />
         <label htmlFor="autoOffAfter" className={`text-sm flex items-center gap-1 ${canControlPrinter ? 'text-bambu-gray' : 'text-bambu-gray/50'}`}>
           <Power className="w-3.5 h-3.5" />
-          Power off printer when done
+          {t('printModal.autoOffAfter')}
         </label>
       </div>
 
@@ -270,7 +291,7 @@ export function ScheduleOptionsPanel({
       )}
 
       {/* Stagger start */}
-      {showStagger && options.scheduleType !== 'manual' && (
+      {showStagger && options.scheduleType !== 'queue' && (
         <div className="space-y-3">
           <div className="flex items-center gap-2">
             <input
@@ -341,10 +362,10 @@ export function ScheduleOptionsPanel({
       {/* Help text */}
       <p className="text-xs text-bambu-gray">
         {options.scheduleType === 'asap'
-          ? 'Print will start as soon as the printer is idle.'
+          ? t('printModal.helpAsap')
           : options.scheduleType === 'scheduled'
-          ? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
-          : "Print will be staged but won't start automatically. Use the Start button to release it to the queue."}
+          ? t('printModal.helpSchedule')
+          : t('printModal.helpQueue')}
       </p>
     </div>
   );

+ 122 - 227
frontend/src/components/PrintModal/index.tsx

@@ -1,8 +1,8 @@
 import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
-import { AlertCircle, AlertTriangle, Calendar, Code, Layers, Loader2, Pencil, Printer, X } from 'lucide-react';
+import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
+import type { PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
 import { api } from '../../api/client';
 import { useAuth } from '../../contexts/AuthContext';
 import { Card, CardContent } from '../Card';
@@ -32,13 +32,12 @@ import type {
 import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
 
 /**
- * Unified PrintModal component that handles three modes:
- * - 'reprint': Immediate print from archive or library file (supports multi-printer)
- * - 'add-to-queue': Schedule print to queue from archive or library file (supports multi-printer)
- * - 'edit-queue-item': Edit existing queue item (supports multi-printer)
+ * Unified PrintModal component that handles queue item creation and editing.
+ * - 'create': Create a print queue item from an archive or library file
+ * - 'edit-queue-item': Edit existing queue item
  *
- * Both archiveId and libraryFileId are supported. Library files can be printed immediately
- * or added to queue (archive is created at print start time, not when queued).
+ * Both archiveId and libraryFileId are supported. Library files are archived at
+ * print start time by the scheduler, not when queued.
  */
 export function PrintModal({
   mode,
@@ -59,6 +58,7 @@ export function PrintModal({
 
   // Determine if we're printing a library file
   const isLibraryFile = !!libraryFileId && !archiveId;
+  const isEditing = mode === 'edit-queue-item';
 
   type FilamentWarningItem = {
     printerName: string;
@@ -79,7 +79,7 @@ export function PrintModal({
     return [];
   });
 
-  // Multi-select plates: in add-to-queue mode users can pick a subset of plates
+  // Multi-select plates: create mode users can pick a subset of plates
   const [selectedPlates, setSelectedPlates] = useState<Set<number>>(() => {
     if (mode === 'edit-queue-item' && queueItem?.plate_id != null) {
       return new Set([queueItem.plate_id]);
@@ -109,10 +109,8 @@ export function PrintModal({
 
   const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
     if (mode === 'edit-queue-item' && queueItem) {
-      let scheduleType: ScheduleType = 'asap';
-      if (queueItem.manual_start) {
-        scheduleType = 'manual';
-      } else if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
+      let scheduleType: ScheduleType = 'queue';
+      if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
         scheduleType = 'scheduled';
       }
 
@@ -126,6 +124,7 @@ export function PrintModal({
       return {
         scheduleType,
         scheduledTime,
+        requireManualStart: queueItem.manual_start,
         requirePreviousSuccess: queueItem.require_previous_success,
         autoOffAfter: queueItem.auto_off_after,
         gcodeInjection: queueItem.gcode_injection ?? false,
@@ -267,7 +266,7 @@ export function PrintModal({
     queryKey: ['spool-assignments'],
     queryFn: () => api.getAssignments(),
     staleTime: 30 * 1000,
-    enabled: ((mode === 'reprint' || mode === 'add-to-queue') && assignmentMode === 'printer') || (isLibraryFile && mode === 'reprint'),
+    enabled: !isEditing && assignmentMode === 'printer',
   });
 
   // Fetch per-printer Map<globalTrayId, gramsRemaining> via the dedicated
@@ -353,12 +352,6 @@ export function PrintModal({
 
   // Combine filament requirements from either source
   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]);
 
   // Fetch available filaments for model-based assignment (for filament override UI)
   const { data: availableFilaments } = useQuery({
@@ -382,6 +375,33 @@ export function PrintModal({
     printerStatus?.ams_filament_backup,
   );
 
+  const isPrinterCurrentlyDispatchable = (status: PrinterStatus | undefined): boolean => {
+    if (!status?.connected) return false;
+    if (status.awaiting_plate_clear) return false;
+    if (status.ams?.some((ams) => ams.dry_time > 0)) return false;
+    return ['IDLE', 'FINISH', 'FAILED'].includes(status.state ?? '');
+  };
+
+  const asapToastShouldPromiseLaterStart = async (): Promise<boolean> => {
+    if (scheduleOptions.scheduleType !== 'asap' || assignmentMode !== 'printer') return false;
+    if (selectedPrinters.length === 0) return false;
+
+    try {
+      const statuses = await Promise.all(
+        selectedPrinters.map((printerId) =>
+          queryClient.fetchQuery({
+            queryKey: ['printer-status', printerId],
+            queryFn: () => api.getPrinterStatus(printerId),
+            staleTime: 0,
+          }),
+        ),
+      );
+      return statuses.some((status) => !isPrinterCurrentlyDispatchable(status));
+    } catch {
+      return true;
+    }
+  };
+
   // Get AMS mapping from hook (only when single printer selected)
   const { amsMapping } = useFilamentMapping(
     effectiveFilamentReqs,
@@ -545,15 +565,13 @@ export function PrintModal({
     },
   });
 
-  const willUseStagger = scheduleOptions.staggerEnabled && selectedPrinters.length > 1;
-
   const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
     e?.preventDefault();
 
     if (
       !options?.skipFilamentCheck &&
       !settings?.disable_filament_warnings &&
-      (mode === 'reprint' || mode === 'add-to-queue') &&
+      !isEditing &&
       assignmentMode === 'printer'
     ) {
       const warningItems: FilamentWarningItem[] = [];
@@ -684,12 +702,12 @@ export function PrintModal({
     const filamentOverridesArray = buildFilamentOverridesArray();
 
     // Multi-plate auto-batch: when the user adds 2+ plates from one source in
-    // a single add-to-queue submission, pre-create a PrintBatch and pass its
+    // a single create submission, pre-create a PrintBatch and pass its
     // id to each subsequent addToQueue call so the queue UI groups them as a
     // collapsible batch. Only triggered for single-target submissions —
     // multi-printer fan-out keeps the old per-item shape.
     const shouldAutoBatch =
-      mode === 'add-to-queue'
+      mode === 'create'
       && platesToQueue.length > 1
       && (assignmentMode === 'model' || selectedPrinters.length === 1);
     let autoBatchId: number | null = null;
@@ -709,7 +727,22 @@ export function PrintModal({
       }
     }
 
-    // Common queue data for add-to-queue and edit modes
+    const asapInsertionCounts = new Map<string, number>();
+
+    const applyAsapInsertion = (
+      queueData: PrintQueueItemCreate,
+      printerId: number | null,
+      itemCount = 1,
+    ) => {
+      if (scheduleOptions.scheduleType !== 'asap') return;
+      const scopeKey = printerId !== null ? `printer:${printerId}` : 'unassigned';
+      const insertPosition = (asapInsertionCounts.get(scopeKey) ?? 0) + 1;
+      queueData.insert_at_top = true;
+      queueData.insert_position = insertPosition;
+      asapInsertionCounts.set(scopeKey, insertPosition + itemCount - 1);
+    };
+
+    // Common queue data for create and edit modes
     const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => ({
       printer_id: assignmentMode === 'printer' ? printerId : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
@@ -721,7 +754,7 @@ export function PrintModal({
       require_previous_success: scheduleOptions.requirePreviousSuccess,
       auto_off_after: scheduleOptions.autoOffAfter,
       gcode_injection: scheduleOptions.gcodeInjection,
-      manual_start: scheduleOptions.scheduleType === 'manual',
+      manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
       // When the user clicks "Print Anyway" on the frontend deficit warning,
       // persist that acknowledgement so the scheduler doesn't immediately
       // re-flag the item on its first dispatch tick (#1698-followup).
@@ -734,16 +767,11 @@ export function PrintModal({
       ...printOptions,
       project_id: projectId ?? undefined,
       batch_id: autoBatchId ?? undefined,
+      cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
     });
 
     // Model-based assignment
     if (assignmentMode === 'model') {
-      if (mode === 'reprint') {
-        showToast('Model-based assignment only works with queue mode', 'error');
-        setIsSubmitting(false);
-        return;
-      }
-
       let progressCounter = 0;
       for (const plate of platesToQueue) {
         progressCounter++;
@@ -761,7 +789,7 @@ export function PrintModal({
               require_previous_success: scheduleOptions.requirePreviousSuccess,
               auto_off_after: scheduleOptions.autoOffAfter,
               gcode_injection: scheduleOptions.gcodeInjection,
-              manual_start: scheduleOptions.scheduleType === 'manual',
+              manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
               ams_mapping: undefined,
               plate_id: plateId,
               scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
@@ -774,6 +802,7 @@ export function PrintModal({
             // Add-to-queue mode with model-based assignment
             const queueData = getQueueData(null, plateId);
             if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
+            applyAsapInsertion(queueData, null, effectiveQuantity);
             await addToQueueMutation.mutateAsync(queueData);
           }
           results.success++;
@@ -787,7 +816,7 @@ export function PrintModal({
       // Printer-based assignment: loop through plates × printers
       // Compute stagger base time once before the loop
       const useStagger = scheduleOptions.staggerEnabled
-        && (mode === 'add-to-queue' || mode === 'reprint')
+        && !isEditing
         && selectedPrinters.length > 1;
       const staggerBaseTime = useStagger
         ? (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
@@ -805,46 +834,7 @@ export function PrintModal({
           setSubmitProgress({ current: progressCounter, total: totalCount });
 
           try {
-            if (mode === 'reprint' && !useStagger) {
-              const printerMapping = getMappingForPrinter(printerId);
-              if (scheduleOptions.gcodeInjection && effectiveQuantity > 1) {
-                // Auto-print injection only happens in the scheduler path. A direct
-                // immediate reprint bypasses it, so the first copy would print without
-                // its end-snippet and stay stuck on the plate — defeating auto-eject and
-                // blocking the injected copies queued behind it. Queue *all* copies so
-                // every one is dispatched (and injected) by the scheduler.
-                const queueData = getQueueData(printerId, plateId);
-                queueData.quantity = effectiveQuantity;
-                await addToQueueMutation.mutateAsync(queueData);
-              } else {
-                // Reprint mode - start print immediately (single plate only, multi-select not available)
-                if (isLibraryFile) {
-                  await api.printLibraryFile(libraryFileId!, printerId, {
-                    plate_id: selectedPlate ?? undefined,
-                    plate_name: selectedPlateName,
-                    ams_mapping: printerMapping,
-                    ...printOptions,
-                    project_id: projectId,
-                    cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
-                  });
-                } else {
-                  // project_id is intentionally omitted here: reprintArchive targets an existing
-                  // archive that already carries its own project association from the original print.
-                  await api.reprintArchive(archiveId!, printerId, {
-                    plate_id: selectedPlate ?? undefined,
-                    plate_name: selectedPlateName,
-                    ams_mapping: printerMapping,
-                    ...printOptions,
-                  });
-                }
-                // Queue remaining copies if quantity > 1
-                if (effectiveQuantity > 1) {
-                  const queueData = getQueueData(printerId, plateId);
-                  queueData.quantity = effectiveQuantity - 1;
-                  await addToQueueMutation.mutateAsync(queueData);
-                }
-              }
-            } else if (mode === 'edit-queue-item' && progressCounter === 1) {
+            if (isEditing && progressCounter === 1) {
               // Edit mode - update the original queue item for the first entry
               const printerMapping = getMappingForPrinter(printerId);
               const updateData: PrintQueueItemUpdate = {
@@ -854,7 +844,7 @@ export function PrintModal({
                 require_previous_success: scheduleOptions.requirePreviousSuccess,
                 auto_off_after: scheduleOptions.autoOffAfter,
                 gcode_injection: scheduleOptions.gcodeInjection,
-                manual_start: scheduleOptions.scheduleType === 'manual',
+                manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
                 ams_mapping: printerMapping,
                 plate_id: plateId,
                 scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
@@ -864,9 +854,10 @@ export function PrintModal({
               };
               await updateQueueMutation.mutateAsync(updateData);
             } else {
-              // Add-to-queue mode, stagger-reprint mode, or edit mode with additional entries
+              // New print mode, staggered print, or edit mode with additional entries
               const queueData = getQueueData(printerId, plateId);
               if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
+              applyAsapInsertion(queueData, printerId, effectiveQuantity);
               // Apply stagger offset for groups after the first
               if (useStagger) {
                 const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
@@ -893,19 +884,28 @@ export function PrintModal({
 
     setIsSubmitting(false);
 
-    // Show result toast (skip for direct reprint — the dispatch toast handles it)
+    // Show result toast
     if (results.failed === 0) {
-      if (mode === 'reprint' && willUseStagger) {
-        // Stagger-reprint routed through queue
-        showToast(t('queue.itemsQueued', { count: results.success }));
-      } else if (mode !== 'reprint') {
+      if (isEditing) {
         if (mode === 'edit-queue-item') {
           showToast('Queue item updated');
-        } else if (results.success === 1) {
-          showToast(assignmentMode === 'model' ? `Queued for any ${targetModel}` : t('queue.printQueued'));
-        } else {
-          showToast(t('queue.itemsQueued', { count: results.success }));
         }
+      } else if (results.success === 1) {
+        const waitForIdleToast = await asapToastShouldPromiseLaterStart();
+        showToast(
+          waitForIdleToast
+            ? t('queue.printQueuedWillStartWhenIdle')
+            : assignmentMode === 'model'
+              ? `Queued for any ${targetModel}`
+              : t('queue.printQueued'),
+        );
+      } else {
+        const waitForIdleToast = await asapToastShouldPromiseLaterStart();
+        showToast(
+          waitForIdleToast
+            ? t('queue.printQueuedWillStartWhenIdle')
+            : t('queue.itemsQueued', { count: results.success }),
+        );
       }
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       onSuccess?.();
@@ -927,26 +927,22 @@ export function PrintModal({
     if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
     if (assignmentMode === 'model' && !targetModel) return false;
 
-    // Model-based assignment only works in queue modes (not immediate reprint)
-    if (assignmentMode === 'model' && mode === 'reprint') return false;
-
     // For multi-plate files, need at least one plate selected
     if (isMultiPlate && selectedPlates.size === 0) return false;
 
     return true;
-  }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlates.size, isPending]);
+  }, [selectedPrinters.length, assignmentMode, targetModel, isMultiPlate, selectedPlates.size, isPending]);
 
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
   const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
 
   // Keep scheduleOptions.gcodeInjection in sync with the checkbox's render
-  // condition. The checkbox only renders for reprint + snippets configured +
+  // condition. The checkbox only renders for create + snippets configured +
   // quantity > 1, so if the user ticks it at quantity 2 then drops back to 1
-  // the box hides but the state stays true — and the immediate-reprint path
-  // would then silently bypass injection.
+  // the box hides but the state stays true.
   useEffect(() => {
     if (
-      mode === 'reprint' &&
+      mode === 'create' &&
       scheduleOptions.gcodeInjection &&
       (effectiveQuantity <= 1 || !settings?.gcode_snippets)
     ) {
@@ -956,41 +952,12 @@ export function PrintModal({
 
   // Modal title and action button text based on mode
   const getModalConfig = () => {
-    const printerCount = selectedPrinters.length;
-
-    if (mode === 'reprint') {
-      const staggerReprint = willUseStagger && printerCount > 1;
-      let submitText = staggerReprint
-        ? t('printModal.staggerToPrinters', { count: printerCount, defaultValue: 'Stagger to {{count}} printers' })
-        : printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print');
-      if (effectiveQuantity > 1) {
-        submitText = `${submitText} ×${effectiveQuantity}`;
-      }
+    if (!isEditing) {
       return {
-        title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
+        title: t('common.print'),
         icon: Printer,
-        submitText,
-        submitIcon: staggerReprint ? Calendar : Printer,
-        loadingText: submitProgress.total > 1
-          ? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
-          : t('queue.sending'),
-      };
-    }
-    if (mode === 'add-to-queue') {
-      let submitText = t('queue.addToQueue');
-      if (selectedPlates.size > 1) {
-        submitText = t('queue.queueSelectedPlates', { count: selectedPlates.size });
-      } else if (printerCount > 1) {
-        submitText = t('queue.queueToPrinters', { count: printerCount });
-      }
-      if (effectiveQuantity > 1) {
-        submitText = `${submitText} ×${effectiveQuantity}`;
-      }
-      return {
-        title: t('queue.schedulePrint'),
-        icon: Calendar,
-        submitText,
-        submitIcon: Calendar,
+        submitText: t('common.print'),
+        submitIcon: Printer,
         loadingText: submitProgress.total > 1
           ? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total })
           : t('queue.adding'),
@@ -1046,13 +1013,9 @@ export function PrintModal({
         className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
         onClick={(e) => e.stopPropagation()}
       >
-        <CardContent className={mode === 'reprint' ? '' : 'p-0'}>
+        <CardContent className="p-0">
           {/* Header */}
-          <div
-            className={`flex items-center justify-between ${
-              mode === 'reprint' ? 'mb-4' : 'p-4 border-b border-bambu-dark-tertiary'
-            }`}
-          >
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
             <div className="flex items-center gap-2">
               <TitleIcon className="w-5 h-5 text-bambu-green" />
               <h2 className="text-lg font-semibold text-white">{modalConfig.title}</h2>
@@ -1062,22 +1025,11 @@ export function PrintModal({
             </Button>
           </div>
 
-          <form onSubmit={handleSubmit} className={mode === 'reprint' ? '' : 'p-4 space-y-4'}>
+          <form onSubmit={handleSubmit} className="p-4 space-y-4">
             {/* Archive name */}
-            <p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
-              {mode === 'reprint' ? (
-                <>
-                  Send <span className="text-white">{archiveName}</span> to{' '}
-                  {initialSelectedPrinterIds?.length === 1 && printers
-                    ? <span className="text-white">{printers.find(p => p.id === initialSelectedPrinterIds[0])?.name ?? 'printer(s)'}</span>
-                    : 'printer(s)'}
-                </>
-              ) : (
-                <>
-                  <span className="block text-bambu-gray mb-1">Print Job</span>
-                  <span className="text-white font-medium truncate block">{archiveName}</span>
-                </>
-              )}
+            <p className="text-sm text-bambu-gray">
+              <span className="block text-bambu-gray mb-1">Print Job</span>
+              <span className="text-white font-medium truncate block">{archiveName}</span>
             </p>
 
             {/* Build-plate badge for the selected (or sole) plate — surfaced
@@ -1108,7 +1060,7 @@ export function PrintModal({
               onToggle={(plateIndex) => {
                 setSelectedPlates(prev => {
                   const next = new Set(prev);
-                  if (mode === 'add-to-queue') {
+                  if (!isEditing) {
                     // Multi-select: toggle the plate
                     if (next.has(plateIndex)) {
                       next.delete(plateIndex);
@@ -1123,9 +1075,9 @@ export function PrintModal({
                   return next;
                 });
               }}
-              onSelectAll={mode === 'add-to-queue' ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined}
-              onDeselectAll={mode === 'add-to-queue' ? () => setSelectedPlates(new Set()) : undefined}
-              multiSelect={mode === 'add-to-queue'}
+              onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined}
+              onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined}
+              multiSelect={!isEditing}
             />
 
             {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
@@ -1137,17 +1089,17 @@ export function PrintModal({
                 isLoading={loadingPrinters}
                 allowMultiple={true}
                 showInactive={mode === 'edit-queue-item'}
-                disableBusy={mode === 'reprint'}
+                disableBusy={false}
                 printerMappingResults={multiPrinterMapping.printerResults}
                 filamentReqs={effectiveFilamentReqs}
                 onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
                 onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
-                assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
-                onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
+                assignmentMode={assignmentMode}
+                onAssignmentModeChange={!isEditing ? setAssignmentMode : undefined}
                 targetModel={targetModel}
-                onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
+                onTargetModelChange={!isEditing ? setTargetModel : undefined}
                 targetLocation={targetLocation}
-                onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
+                onTargetLocationChange={!isEditing ? setTargetLocation : undefined}
                 slicedForModel={slicedForModel}
               />
             )}
@@ -1210,7 +1162,7 @@ export function PrintModal({
             )}
 
             {/* Print options */}
-            {(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
+            {(mode === 'create' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
               <PrintOptionsPanel
                 options={printOptions}
                 onChange={setPrintOptions}
@@ -1242,74 +1194,17 @@ export function PrintModal({
               </div>
             )}
 
-            {/* Stagger option for reprint mode with multiple printers */}
-            {mode === 'reprint' && assignmentMode === 'printer' && selectedPrinters.length > 1 && (
-              <div className="space-y-2 pb-2">
-                <div className="flex items-center gap-2">
-                  <input
-                    type="checkbox"
-                    id="staggerEnabledReprint"
-                    checked={scheduleOptions.staggerEnabled}
-                    onChange={(e) => setScheduleOptions({ ...scheduleOptions, staggerEnabled: e.target.checked })}
-                    className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-                  />
-                  <label htmlFor="staggerEnabledReprint" className="text-sm flex items-center gap-1 text-bambu-gray">
-                    <Layers className="w-3.5 h-3.5" />
-                    {t('printModal.staggerPrinterStarts', 'Stagger printer starts')}
-                  </label>
-                </div>
-                {scheduleOptions.staggerEnabled && (() => {
-                  const groupSize = scheduleOptions.staggerGroupSize;
-                  const interval = scheduleOptions.staggerIntervalMinutes;
-                  const groupCount = Math.ceil(selectedPrinters.length / groupSize);
-                  const totalMinutes = (groupCount - 1) * interval;
-                  return (
-                    <p className="ml-6 text-xs text-bambu-gray">
-                      {t('printModal.staggerPreview', '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min', {
-                        printers: selectedPrinters.length,
-                        groups: groupCount,
-                        size: groupSize,
-                        interval,
-                      })}
-                      {groupCount > 1
-                        ? ` (${t('printModal.staggerTotal', 'total: {{minutes}} min', { minutes: totalMinutes })})`
-                        : ''}
-                    </p>
-                  );
-                })()}
-              </div>
-            )}
-
-            {/* Schedule options - only for queue modes */}
-            {mode !== 'reprint' && (
-              <ScheduleOptionsPanel
-                options={scheduleOptions}
-                onChange={setScheduleOptions}
-                dateFormat={settings?.date_format || 'system'}
-                timeFormat={settings?.time_format || 'system'}
-                canControlPrinter={hasPermission('printers:control')}
-                showStagger={mode === 'add-to-queue' && assignmentMode === 'printer' && selectedPrinters.length > 1}
-                printerCount={selectedPrinters.length}
-                hasGcodeSnippets={!!settings?.gcode_snippets}
-              />
-            )}
-
-            {/* G-code injection for reprint mode (only shown when quantity > 1 — applies to queued copies) */}
-            {mode === 'reprint' && !!settings?.gcode_snippets && effectiveQuantity > 1 && (
-              <div className="flex items-center gap-2">
-                <input
-                  type="checkbox"
-                  id="gcodeInjectionReprint"
-                  checked={scheduleOptions.gcodeInjection}
-                  onChange={(e) => setScheduleOptions({ ...scheduleOptions, gcodeInjection: e.target.checked })}
-                  className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-                />
-                <label htmlFor="gcodeInjectionReprint" className="text-sm flex items-center gap-1 text-bambu-gray">
-                  <Code className="w-3.5 h-3.5" />
-                  {t('printModal.gcodeInjection', 'Inject auto-print G-code')}
-                </label>
-              </div>
-            )}
+            {/* Schedule options */}
+            <ScheduleOptionsPanel
+              options={scheduleOptions}
+              onChange={setScheduleOptions}
+              dateFormat={settings?.date_format || 'system'}
+              timeFormat={settings?.time_format || 'system'}
+              canControlPrinter={hasPermission('printers:control')}
+              showStagger={!isEditing && assignmentMode === 'printer' && selectedPrinters.length > 1}
+              printerCount={selectedPrinters.length}
+              hasGcodeSnippets={!!settings?.gcode_snippets}
+            />
 
             {/* Error message */}
             {updateQueueMutation.isError && (
@@ -1319,7 +1214,7 @@ export function PrintModal({
             )}
 
             {/* Actions */}
-            <div className={`flex gap-3 ${mode === 'reprint' ? '' : 'pt-2'}`}>
+            <div className="flex gap-3 pt-2">
               <Button type="button" variant="secondary" onClick={onClose} className="flex-1" disabled={isSubmitting}>
                 Cancel
               </Button>

+ 7 - 6
frontend/src/components/PrintModal/types.ts

@@ -2,11 +2,10 @@ import type { PrintQueueItem, Printer } from '../../api/client';
 
 /**
  * Mode of operation for the PrintModal.
- * - 'reprint': Immediate print from archive (no schedule options)
- * - 'add-to-queue': Schedule print to queue (includes schedule options)
+ * - 'create': Create a print queue item from an archive or library file
  * - 'edit-queue-item': Edit existing queue item (all options + existing values)
  */
-export type PrintModalMode = 'reprint' | 'add-to-queue' | 'edit-queue-item';
+export type PrintModalMode = 'create' | 'edit-queue-item';
 
 /**
  * Props for the unified PrintModal component.
@@ -66,7 +65,7 @@ export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
 /**
  * Schedule type for queue items.
  */
-export type ScheduleType = 'asap' | 'scheduled' | 'manual';
+export type ScheduleType = 'asap' | 'queue' | 'scheduled';
 
 /**
  * Schedule options for queue items.
@@ -74,6 +73,7 @@ export type ScheduleType = 'asap' | 'scheduled' | 'manual';
 export interface ScheduleOptions {
   scheduleType: ScheduleType;
   scheduledTime: string;
+  requireManualStart: boolean;
   requirePreviousSuccess: boolean;
   autoOffAfter: boolean;
   gcodeInjection: boolean;
@@ -88,6 +88,7 @@ export interface ScheduleOptions {
 export const DEFAULT_SCHEDULE_OPTIONS: ScheduleOptions = {
   scheduleType: 'asap',
   scheduledTime: '',
+  requireManualStart: false,
   requirePreviousSuccess: false,
   autoOffAfter: false,
   gcodeInjection: false,
@@ -140,7 +141,7 @@ export interface PrinterSelectorProps {
   allowMultiple?: boolean;
   /** Show inactive printers (for edit mode where original assignment may be inactive) */
   showInactive?: boolean;
-  /** Disable selection of busy printers (used in reprint mode) */
+  /** Disable selection of busy printers */
   disableBusy?: boolean;
   /** Current assignment mode */
   assignmentMode?: AssignmentMode;
@@ -168,7 +169,7 @@ export interface PlateSelectorProps {
   onToggle: (plateIndex: number) => void;
   onSelectAll?: () => void;
   onDeselectAll?: () => void;
-  /** Whether multi-select (checkboxes) is enabled — true in add-to-queue mode */
+  /** Whether multi-select (checkboxes) is enabled */
   multiSelect?: boolean;
 }
 

+ 2 - 3
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -25,9 +25,8 @@ export function SpoolBuddyLayout() {
   const location = useLocation();
   const sbState = useSpoolBuddyState();
 
-  // Hide the global toast viewport (background-dispatch progress, etc.) on the
-  // kiosk display. Restore on unmount so navigating back to the main app sees
-  // its toasts again.
+  // Hide the global toast viewport on the kiosk display. Restore on unmount so
+  // navigating back to the main app sees its toasts again.
   const { setViewportSuppressed } = useToast();
   useEffect(() => {
     setViewportSuppressed(true);

+ 24 - 532
frontend/src/contexts/ToastContext.tsx

@@ -1,8 +1,5 @@
-import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCircle } from 'lucide-react';
+import { AlertCircle, CheckCircle, 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';
-import { formatFileSize } from '../utils/file';
 
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
 
@@ -25,29 +22,6 @@ interface Toast {
   type: ToastType;
   persistent?: boolean;
   action?: ToastAction;
-  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 {
@@ -57,8 +31,7 @@ interface ToastContextType {
   /**
    * Suppress the visible toast viewport while keeping the state machine alive.
    * Used by the SpoolBuddy kiosk layout to keep the kiosk display free of
-   * main-app notifications (background dispatch progress, etc.) without
-   * tearing down the dispatch-job subscription that other tabs rely on.
+   * main-app notifications.
    */
   setViewportSuppressed: (suppressed: boolean) => void;
 }
@@ -90,14 +63,9 @@ const bgColors = {
 };
 
 export function ToastProvider({ children }: { children: ReactNode }) {
-  const { t } = useTranslation();
   const [toasts, setToasts] = useState<Toast[]>([]);
-  const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
   const [viewportSuppressed, setViewportSuppressed] = useState(false);
-  const [cancellingDispatchJobIds, setCancellingDispatchJobIds] = useState<Set<number>>(new Set());
   const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
-  const dispatchToastId = 'background-dispatch';
-  const lastDispatchSummaryRef = useRef<string | null>(null);
   // Tracks whether the provider is still mounted. A toast can be triggered by
   // an async callback that resolves AFTER React has unmounted us (common in
   // tests: `cleanup()` runs while a login promise is still in flight, then
@@ -160,349 +128,6 @@ export function ToastProvider({ children }: { children: ReactNode }) {
     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;
-      const recentStatus = detail.recent_event?.status;
-
-      // Once any print starts successfully, dismiss the dispatch toast (#615)
-      // Remaining jobs continue in the background silently
-      if (recentStatus === 'completed' && completed > 0) {
-        const summaryKey = `first-complete:${completed}:${failed}`;
-        if (lastDispatchSummaryRef.current !== summaryKey) {
-          lastDispatchSummaryRef.current = summaryKey;
-
-          const remaining = total - completed - failed;
-          const doneMessage = remaining > 0
-            ? t('backgroundDispatch.toast.printStartedRemaining', { completed, remaining })
-            : failed > 0
-              ? t('backgroundDispatch.toast.completeWithFailures', { completed, failed })
-              : t('backgroundDispatch.toast.completeSuccess', { completed });
-
-          setToasts((prev) => {
-            const doneToast: Toast = {
-              id: dispatchToastId,
-              message: doneMessage,
-              type: failed > 0 ? 'warning' : 'success',
-              persistent: true,
-            };
-            const exists = prev.find((toastItem) => toastItem.id === dispatchToastId);
-            if (exists) {
-              return prev.map((toastItem) =>
-                toastItem.id === dispatchToastId ? doneToast : toastItem
-              );
-            }
-            return [...prev, doneToast];
-          });
-
-          const existingTimeout = timeoutRefs.current.get(dispatchToastId);
-          if (existingTimeout) clearTimeout(existingTimeout);
-          const timeout = setTimeout(() => {
-            if (!isMountedRef.current) return;
-            setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
-            timeoutRefs.current.delete(dispatchToastId);
-            lastDispatchSummaryRef.current = null;
-          }, 3000);
-          timeoutRefs.current.set(dispatchToastId, timeout);
-        }
-        return;
-      }
-
-      if (hasActiveWork) {
-        // New batch starting — reset dedup guard so completion toast works
-        lastDispatchSummaryRef.current = null;
-        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;
-      }
-
-      if (allDone) {
-        const summaryKey = `${completed}:${failed}`;
-        if (lastDispatchSummaryRef.current === summaryKey) {
-          return;
-        }
-        lastDispatchSummaryRef.current = summaryKey;
-
-        const doneMessage = failed > 0
-          ? t('backgroundDispatch.toast.completeWithFailures', { completed, failed })
-          : t('backgroundDispatch.toast.completeSuccess', { completed });
-
-        // Show a brief "completed" state on the dispatch toast before replacing with summary
-        // This ensures the user sees confirmation even for fast uploads (#615)
-        setToasts((prev) => {
-          const doneToast: Toast = {
-            id: dispatchToastId,
-            message: doneMessage,
-            type: failed > 0 ? 'warning' : 'success',
-            persistent: true,
-            // Clear dispatchData so it renders as a simple text toast
-          };
-          const exists = prev.find((toastItem) => toastItem.id === dispatchToastId);
-          if (exists) {
-            return prev.map((toastItem) =>
-              toastItem.id === dispatchToastId ? doneToast : toastItem
-            );
-          }
-          return [...prev, doneToast];
-        });
-
-        // Auto-dismiss after 3 seconds
-        const existingTimeout = timeoutRefs.current.get(dispatchToastId);
-        if (existingTimeout) clearTimeout(existingTimeout);
-        const timeout = setTimeout(() => {
-          setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
-          timeoutRefs.current.delete(dispatchToastId);
-          lastDispatchSummaryRef.current = null;
-        }, 3000);
-        timeoutRefs.current.set(dispatchToastId, timeout);
-        return;
-      }
-
-      if (!hasActiveWork && recentStatus && ['cancelled', 'failed', 'completed', 'idle'].includes(recentStatus)) {
-        setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
-        lastDispatchSummaryRef.current = null;
-      }
-
-      if (detail.recent_event?.status === 'idle' && !hasActiveWork) {
-        setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
-        lastDispatchSummaryRef.current = null;
-      }
-
-      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 (
     <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast, setViewportSuppressed }}>
       {children}
@@ -514,163 +139,30 @@ export function ToastProvider({ children }: { children: ReactNode }) {
         {toasts.map((toast) => (
           <div
             key={toast.id}
-            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'
-            }`}
+            className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} flex items-center gap-3 px-4 py-3`}
           >
-            {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,
-                      };
-                      // Upload byte count reached the total — the printer hasn't yet
-                      // confirmed it received the file (state is still 'processing').
-                      // Without distinguishing this we show a frozen 100% bar that
-                      // reads as "stuck" on small files where the upload completed
-                      // in <500ms.
-                      const uploadDoneAwaitingPrinter =
-                        job.status === 'processing' &&
-                        typeof job.uploadProgressPct === 'number' &&
-                        job.uploadProgressPct >= 99.9;
-                      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' && (
-                            uploadDoneAwaitingPrinter ? (
-                              <div className="text-[11px] text-bambu-gray truncate">
-                                {t('backgroundDispatch.awaitingPrinter')}
-                              </div>
-                            ) : typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 ? (
-                              <div className="text-[11px] text-bambu-gray truncate">
-                                {formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
-                                {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
-                              </div>
-                            ) : null
-                          )}
-                          <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 ${uploadDoneAwaitingPrinter ? 'animate-pulse' : ''}`}
-                              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>
-                {toast.action && (
-                  <a
-                    href={toast.action.href}
-                    target="_blank"
-                    rel="noopener noreferrer"
-                    onClick={() => {
-                      toast.action?.onClick?.();
-                      dismissToast(toast.id);
-                    }}
-                    className="ml-2 px-2 py-1 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 whitespace-nowrap"
-                  >
-                    {toast.action.label}
-                  </a>
-                )}
-                <button
-                  onClick={() => dismissToast(toast.id)}
-                  className="ml-2 text-bambu-gray hover:text-white transition-colors"
-                >
-                  <X className="w-4 h-4" />
-                </button>
-              </>
+            {icons[toast.type]}
+            <span className="text-white text-sm">{toast.message}</span>
+            {toast.action && (
+              <a
+                href={toast.action.href}
+                target="_blank"
+                rel="noopener noreferrer"
+                onClick={() => {
+                  toast.action?.onClick?.();
+                  dismissToast(toast.id);
+                }}
+                className="ml-2 px-2 py-1 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 whitespace-nowrap"
+              >
+                {toast.action.label}
+              </a>
             )}
+            <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>

+ 0 - 85
frontend/src/hooks/useDispatchedPrinterIds.ts

@@ -1,85 +0,0 @@
-/**
- * Subscribes to background-dispatch WebSocket events and returns the set of
- * printer IDs that currently have a queued or active dispatch job.
- *
- * Used by PrinterSelector to disable printers between the moment Bambuddy
- * accepts a dispatch (FTP upload, print command) and the moment the printer
- * itself reports PRINT_START. The backend already rejects double-sends with
- * HTTP 409, but the UI gap still let operators pick a printer the server would
- * refuse — surfaced by a corporate user running multi-operator farm shifts.
- *
- * Module-level state + useSyncExternalStore so every PrinterSelector instance
- * sees the same snapshot, and component mounts mid-batch pick up the latest
- * state without re-fetching.
- */
-import { useSyncExternalStore } from 'react';
-
-interface DispatchEventJob {
-  printer_id?: unknown;
-}
-
-interface DispatchEventDetail {
-  dispatched_jobs?: DispatchEventJob[];
-  active_jobs?: DispatchEventJob[];
-  total?: number;
-  dispatched?: number;
-  processing?: number;
-}
-
-const EMPTY: ReadonlySet<number> = new Set();
-let currentSet: ReadonlySet<number> = EMPTY;
-const subscribers = new Set<() => void>();
-let attached = false;
-
-function recompute(detail: DispatchEventDetail): ReadonlySet<number> {
-  const next = new Set<number>();
-  for (const job of detail.dispatched_jobs ?? []) {
-    if (typeof job.printer_id === 'number') next.add(job.printer_id);
-  }
-  for (const job of detail.active_jobs ?? []) {
-    if (typeof job.printer_id === 'number') next.add(job.printer_id);
-  }
-  return next;
-}
-
-function handleEvent(event: Event) {
-  const detail = (event as CustomEvent<DispatchEventDetail>).detail ?? {};
-  const next = recompute(detail);
-  // Keep reference stable when content didn't change — useSyncExternalStore
-  // compares snapshots via Object.is and re-renders on any new reference.
-  if (next.size === currentSet.size && [...next].every((id) => currentSet.has(id))) {
-    return;
-  }
-  currentSet = next;
-  subscribers.forEach((cb) => cb());
-}
-
-function ensureAttached() {
-  if (attached || typeof window === 'undefined') return;
-  window.addEventListener('background-dispatch', handleEvent);
-  attached = true;
-}
-
-const subscribe = (callback: () => void): (() => void) => {
-  ensureAttached();
-  subscribers.add(callback);
-  return () => {
-    subscribers.delete(callback);
-  };
-};
-
-const getSnapshot = (): ReadonlySet<number> => currentSet;
-
-export function useDispatchedPrinterIds(): ReadonlySet<number> {
-  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
-}
-
-/** Test-only helper — resets the module-level singleton between tests. */
-export function __resetDispatchedPrinterIdsForTests(): void {
-  currentSet = EMPTY;
-  subscribers.clear();
-  if (attached && typeof window !== 'undefined') {
-    window.removeEventListener('background-dispatch', handleEvent);
-    attached = false;
-  }
-}

+ 0 - 8
frontend/src/hooks/useWebSocket.ts

@@ -342,14 +342,6 @@ export function useWebSocket() {
         break;
       }
 
-      case 'background_dispatch':
-        window.dispatchEvent(
-          new CustomEvent('background-dispatch', {
-            detail: (message as unknown as { data?: Record<string, unknown> }).data || {},
-          })
-        );
-        break;
-
       case 'spoolbuddy_weight':
         window.dispatchEvent(new CustomEvent('spoolbuddy-weight', { detail: message }));
         break;

+ 4 - 2
frontend/src/i18n/index.ts

@@ -72,7 +72,9 @@ i18n
  */
 function applyApplianceLocale() {
   if (typeof window === 'undefined' || !window.localStorage) return;
-  if (window.localStorage.getItem(APPLIANCE_CONSUMED_KEY)) return;
+  const storage = window.localStorage;
+  if (typeof storage.getItem !== 'function' || typeof storage.setItem !== 'function') return;
+  if (storage.getItem(APPLIANCE_CONSUMED_KEY)) return;
 
   fetch('/api/v1/system/appliance')
     .then((r) => (r.ok ? r.json() : null))
@@ -80,7 +82,7 @@ function applyApplianceLocale() {
       if (!data || typeof data.locale !== 'string') return;
       if (!SUPPORTED_LNGS.includes(data.locale)) return;
       i18n.changeLanguage(data.locale);
-      window.localStorage.setItem(APPLIANCE_CONSUMED_KEY, '1');
+      storage.setItem(APPLIANCE_CONSUMED_KEY, '1');
     })
     .catch(() => {
       // Endpoint absent or unreachable — non-appliance install or dev environment.

+ 14 - 49
frontend/src/i18n/locales/de.ts

@@ -747,7 +747,6 @@ export default {
     printTime: 'Druckzeit',
     filamentUsed: 'Verbrauchtes Filament',
     cost: 'Kosten',
-    reprint: 'Drucken',
     preview: 'Vorschau',
     deleteArchive: 'Archiv löschen',
     deleteConfirm: 'Möchten Sie dieses Archiv wirklich löschen?',
@@ -794,7 +793,6 @@ export default {
     },
     menu: {
       print: 'Drucken',
-      schedule: 'Planen',
       openInBambuStudio: 'Im Slicer öffnen',
       slice: 'Slicen',
       externalLink: 'Externer Link',
@@ -889,9 +887,6 @@ export default {
       noFileForReprint: 'Keine 3MF-Datei verfügbar — die Datei konnte beim Aufzeichnen des Drucks nicht vom Drucker heruntergeladen werden',
       noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',
       noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
-      reprint: 'Drucken',
-      schedulePrint: 'Druck planen',
-      schedule: 'Planen',
       openInBambuStudio: 'Im Slicer öffnen',
       openInBambuStudioToSlice: 'Im Slicer öffnen zum Slicen',
       slice: 'Slicen',
@@ -1032,18 +1027,12 @@ export default {
     },
     title: 'Druckwarteschlange',
     subtitle: 'Planen und verwalten Sie Ihre Druckaufträge',
-    addToQueue: 'Zur Warteschlange hinzufügen',
     // Print modal
-    print: 'Drucken',
-    reprint: 'Erneut drucken',
-    schedulePrint: 'Druck planen',
     editQueueItem: 'Warteschlangeneintrag bearbeiten',
-    printToPrinters: 'Auf {{count}} Druckern drucken',
-    queueToPrinters: 'Zu {{count}} Druckern hinzufügen',
-    queueSelectedPlates: '{{count}} Platten in die Warteschlange',
     selectAllPlates: 'Alle {{count}} Platten auswählen',
     deselectAll: 'Alle abwählen',
     printQueued: 'Druck in Warteschlange',
+    printQueuedWillStartWhenIdle: 'Startet, sobald der Drucker im Leerlauf ist',
     itemsQueued: '{{count}} Einträge in Warteschlange',
     sending: 'Wird gesendet...',
     sendingProgress: 'Sende {{current}}/{{total}}...',
@@ -1185,8 +1174,8 @@ export default {
     },
     // Actions
     actions: {
-      stopPrint: 'Druck stoppen',
       startPrint: 'Druck starten',
+      stopPrint: 'Druck stoppen',
       requeue: 'Erneut einreihen',
     },
     // Bulk edit
@@ -1304,35 +1293,6 @@ 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…',
-    awaitingPrinter: 'Warte auf Drucker…',
-    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',
-      printStartedRemaining: '{{completed}} Druck(e) gestartet, {{remaining}} weitere werden gesendet...',
-    },
-  },
-
   // Statistics page
   stats: {
     title: 'Statistiken',
@@ -3354,8 +3314,6 @@ export default {
     changeLink: 'Verknüpfung ändern...',
     linkTo: 'Verknüpfen mit...',
     linkToProjectOrArchive: 'Mit Projekt oder Archiv verknüpfen',
-    addToQueue: 'Zur Warteschlange',
-    schedulePrint: 'Planen',
     generateThumbnail: 'Vorschaubild generieren',
     generateThumbnails: 'Vorschaubilder generieren',
     generateThumbnailsForMissing: 'Vorschaubilder für STL-Dateien ohne Vorschau generieren',
@@ -3658,8 +3616,6 @@ export default {
       fileCount: '{{count}} Datei(en)',
       empty: 'Keine Ordner verknüpft. Gehen Sie zum Dateimanager und verknüpfen Sie einen Ordner mit diesem Projekt.',
       noFiles: 'Keine Dateien in diesem Ordner.',
-      print: 'Jetzt drucken',
-      addToQueue: 'Zur Warteschlange',
     },
     bom: {
       title: 'Stückliste',
@@ -4298,7 +4254,6 @@ export default {
 
   // Print modal
   printModal: {
-    title: 'Druck starten',
     selectPrinter: 'Drucker auswählen',
     selectPlate: 'Platte auswählen',
     filamentMapping: 'Filamentzuordnung',
@@ -4310,15 +4265,25 @@ export default {
     vibrationCalibration: 'Vibrations-Kalibrierung',
     layerInspection: 'Erste-Schicht-Prüfung',
     timelapse: 'Zeitraffer',
-    startPrint: 'Druck starten',
-    addToQueue: 'Zur Warteschlange hinzufügen',
     cancel: 'Abbrechen',
     noPrintersAvailable: 'Keine Drucker verfügbar',
     printerBusy: 'Drucker ist beschäftigt',
     printerOffline: 'Drucker ist offline',
     sameTypeDifferentColor: 'Gleicher Typ, andere Farbe',
     filamentTypeNotLoaded: 'Filamenttyp nicht geladen',
+    whenToPrint: 'Wann drucken',
+    asap: 'Sofort',
+    queue: 'Warteschlange',
+    schedule: 'Planen',
+    dateTime: 'Datum & Uhrzeit',
+    invalidDateTime: 'Bitte ein gültiges Datum und eine gültige Uhrzeit eingeben',
     openCalendar: 'Kalender öffnen',
+    requireManualStart: 'Manuellen Start erfordern',
+    requirePreviousSuccess: 'Nur starten, wenn der vorherige Druck erfolgreich war',
+    autoOffAfter: 'Drucker nach Abschluss ausschalten',
+    helpAsap: 'Der Druck wird oben in die Warteschlange eingefügt und startet, sobald ein geeigneter Drucker im Leerlauf ist.',
+    helpSchedule: 'Der Druck startet zur geplanten Zeit, wenn der Drucker im Leerlauf ist. Wenn er belegt ist, wartet er, bis der Drucker verfügbar ist.',
+    helpQueue: 'Der Druck wird hinten in die Warteschlange eingefügt.',
     leftNozzle: 'L',
     rightNozzle: 'R',
     leftNozzleTooltip: 'Linke Düse',

+ 14 - 49
frontend/src/i18n/locales/en.ts

@@ -751,7 +751,6 @@ export default {
     printTime: 'Print Time',
     filamentUsed: 'Filament Used',
     cost: 'Cost',
-    reprint: 'Reprint',
     preview: 'Preview',
     deleteArchive: 'Delete Archive',
     deleteConfirm: 'Are you sure you want to delete this archive?',
@@ -798,7 +797,6 @@ export default {
     },
     menu: {
       print: 'Print',
-      schedule: 'Schedule',
       openInBambuStudio: 'Open in Slicer',
       slice: 'Slice',
       externalLink: 'External Link',
@@ -893,9 +891,6 @@ export default {
       noFileForReprint: 'No 3MF file available — the file could not be downloaded from the printer when the print was recorded',
       noPermissionEdit: 'You do not have permission to edit archives',
       noPermissionDelete: 'You do not have permission to delete archives',
-      reprint: 'Reprint',
-      schedulePrint: 'Schedule Print',
-      schedule: 'Schedule',
       openInBambuStudio: 'Open in Slicer',
       openInBambuStudioToSlice: 'Open in Slicer to slice',
       slice: 'Slice',
@@ -1027,7 +1022,6 @@ export default {
   queue: {
     title: 'Print Queue',
     subtitle: 'Schedule and manage your print jobs',
-    addToQueue: 'Add to Queue',
     filamentShort: {
       rowBadge: 'Insufficient filament for the assigned spool',
       rowTooltip: 'The dispatch scheduler flagged this item. Click Play to see the per-slot deficit and decide whether to print anyway.',
@@ -1038,16 +1032,11 @@ export default {
       printAnyway: 'Print Anyway',
     },
     // Print modal
-    print: 'Print',
-    reprint: 'Re-print',
-    schedulePrint: 'Schedule Print',
     editQueueItem: 'Edit Queue Item',
-    printToPrinters: 'Print to {{count}} Printers',
-    queueToPrinters: 'Queue to {{count}} Printers',
-    queueSelectedPlates: 'Queue {{count}} Plates',
     selectAllPlates: 'Select All {{count}} Plates',
     deselectAll: 'Deselect All',
     printQueued: 'Print queued',
+    printQueuedWillStartWhenIdle: 'Will start when printer is idle',
     itemsQueued: '{{count}} items queued',
     sending: 'Sending...',
     sendingProgress: 'Sending {{current}}/{{total}}...',
@@ -1195,8 +1184,8 @@ export default {
     },
     // Actions
     actions: {
-      stopPrint: 'Stop Print',
       startPrint: 'Start Print',
+      stopPrint: 'Stop Print',
       requeue: 'Re-queue',
     },
     // Bulk edit
@@ -1315,35 +1304,6 @@ 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…',
-    awaitingPrinter: 'Awaiting printer…',
-    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',
-      printStartedRemaining: '{{completed}} print(s) started, {{remaining}} more sending...',
-    },
-  },
-
   // Statistics page
   stats: {
     title: 'Statistics',
@@ -3369,8 +3329,6 @@ export default {
     changeLink: 'Change Link...',
     linkTo: 'Link to...',
     linkToProjectOrArchive: 'Link to project or archive',
-    addToQueue: 'Add to Queue',
-    schedulePrint: 'Schedule',
     generateThumbnail: 'Generate Thumbnail',
     generateThumbnails: 'Generate Thumbnails',
     generateThumbnailsForMissing: 'Generate thumbnails for STL files missing them',
@@ -3673,8 +3631,6 @@ export default {
       fileCount: '{{count}} file(s)',
       empty: 'No folders linked. Go to File Manager and link a folder to this project.',
       noFiles: 'No files in this folder.',
-      print: 'Print Now',
-      addToQueue: 'Add to Queue',
     },
     bom: {
       title: 'Bill of Materials',
@@ -4322,7 +4278,6 @@ export default {
 
   // Print modal
   printModal: {
-    title: 'Start Print',
     selectPrinter: 'Select Printer',
     selectPlate: 'Select Plate',
     filamentMapping: 'Filament Mapping',
@@ -4334,15 +4289,25 @@ export default {
     vibrationCalibration: 'Vibration Calibration',
     layerInspection: 'First Layer Inspection',
     timelapse: 'Timelapse',
-    startPrint: 'Start Print',
-    addToQueue: 'Add to Queue',
     cancel: 'Cancel',
     noPrintersAvailable: 'No printers available',
     printerBusy: 'Printer is busy',
     printerOffline: 'Printer is offline',
     sameTypeDifferentColor: 'Same type, different color',
     filamentTypeNotLoaded: 'Filament type not loaded',
+    whenToPrint: 'When to print',
+    asap: 'ASAP',
+    queue: 'Queue',
+    schedule: 'Schedule',
+    dateTime: 'Date & Time',
+    invalidDateTime: 'Please enter a valid date and time',
     openCalendar: 'Open calendar',
+    requireManualStart: 'Require manual start',
+    requirePreviousSuccess: 'Only start if previous print succeeded',
+    autoOffAfter: 'Power off printer when done',
+    helpAsap: 'Print will be added to the top of the queue and start as soon as an eligible printer is idle.',
+    helpSchedule: 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.',
+    helpQueue: 'Print will be added to the back of the queue.',
     leftNozzle: 'L',
     rightNozzle: 'R',
     leftNozzleTooltip: 'Left nozzle',

+ 14 - 49
frontend/src/i18n/locales/es.ts

@@ -747,7 +747,6 @@ export default {
     printTime: 'Tiempo de impresión',
     filamentUsed: 'Filamento usado',
     cost: 'Coste',
-    reprint: 'Reimprimir',
     preview: 'Vista previa',
     deleteArchive: 'Eliminar archivo',
     deleteConfirm: '¿Está seguro de que desea eliminar este archivo?',
@@ -794,7 +793,6 @@ export default {
     },
     menu: {
       print: 'Imprimir',
-      schedule: 'Programar',
       openInBambuStudio: 'Abrir en el laminador',
       slice: 'Laminar',
       externalLink: 'Enlace externo',
@@ -889,9 +887,6 @@ export default {
       noFileForReprint: 'No hay archivo 3MF disponible — no se pudo descargar el archivo de la impresora cuando se registró la impresión',
       noPermissionEdit: 'No tiene permiso para editar archivos',
       noPermissionDelete: 'No tiene permiso para eliminar archivos',
-      reprint: 'Reimprimir',
-      schedulePrint: 'Programar impresión',
-      schedule: 'Programar',
       openInBambuStudio: 'Abrir en el laminador',
       openInBambuStudioToSlice: 'Abrir en el laminador para laminar',
       slice: 'Laminar',
@@ -1032,18 +1027,12 @@ export default {
     },
     title: 'Cola de impresión',
     subtitle: 'Programe y gestione sus trabajos de impresión',
-    addToQueue: 'Añadir a la cola',
     // Print modal
-    print: 'Imprimir',
-    reprint: 'Reimprimir',
-    schedulePrint: 'Programar impresión',
     editQueueItem: 'Editar elemento de la cola',
-    printToPrinters: 'Imprimir en {{count}} impresoras',
-    queueToPrinters: 'Encolar en {{count}} impresoras',
-    queueSelectedPlates: 'Encolar {{count}} camas',
     selectAllPlates: 'Seleccionar las {{count}} camas',
     deselectAll: 'Deseleccionar todo',
     printQueued: 'Impresión encolada',
+    printQueuedWillStartWhenIdle: 'Comenzará cuando la impresora esté inactiva',
     itemsQueued: '{{count}} elementos encolados',
     sending: 'Enviando...',
     sendingProgress: 'Enviando {{current}}/{{total}}...',
@@ -1185,8 +1174,8 @@ export default {
     },
     // Actions
     actions: {
-      stopPrint: 'Detener impresión',
       startPrint: 'Iniciar impresión',
+      stopPrint: 'Detener impresión',
       requeue: 'Volver a encolar',
     },
     // Bulk edit
@@ -1304,35 +1293,6 @@ export default {
     },
   },
 
-  backgroundDispatch: {
-    unknownFile: 'Archivo desconocido',
-    unknownPrinter: 'Impresora desconocida',
-    startingPrints: 'Iniciando impresiones',
-    progressSummary: '{{complete}}/{{total}} completadas • Enviadas: {{dispatched}} • Procesando: {{processing}}',
-    expandDetails: 'Expandir los detalles del envío',
-    collapseDetails: 'Contraer los detalles del envío',
-    dismissToast: 'Descartar la notificación de envío',
-    cancelDispatchJob: 'Cancelar el trabajo de envío',
-    cancel: 'Cancelar',
-    cancelling: 'Cancelando…',
-    awaitingPrinter: 'Esperando a la impresora…',
-    status: {
-      dispatched: 'Enviada',
-      processing: 'Procesando',
-      completed: 'Completada',
-      failed: 'Fallida',
-      cancelled: 'Cancelada',
-    },
-    toast: {
-      cancellingUpload: 'Cancelando la subida...',
-      cancelled: 'Envío cancelado',
-      cancelFailed: 'Error al cancelar el envío',
-      completeWithFailures: 'Envío en segundo plano completado: {{completed}} con éxito, {{failed}} con error',
-      completeSuccess: 'Envío en segundo plano completado: {{completed}} con éxito',
-      printStartedRemaining: '{{completed}} impresión(es) iniciada(s), enviando {{remaining}} más...',
-    },
-  },
-
   // Statistics page
   stats: {
     title: 'Estadísticas',
@@ -3357,8 +3317,6 @@ export default {
     changeLink: 'Cambiar enlace...',
     linkTo: 'Vincular a...',
     linkToProjectOrArchive: 'Vincular a un proyecto o archivo',
-    addToQueue: 'Añadir a la cola',
-    schedulePrint: 'Programar',
     generateThumbnail: 'Generar miniatura',
     generateThumbnails: 'Generar miniaturas',
     generateThumbnailsForMissing: 'Generar miniaturas para los archivos STL que no las tienen',
@@ -3661,8 +3619,6 @@ export default {
       fileCount: '{{count}} archivo(s)',
       empty: 'No hay carpetas vinculadas. Vaya al gestor de archivos y vincule una carpeta a este proyecto.',
       noFiles: 'No hay archivos en esta carpeta.',
-      print: 'Imprimir ahora',
-      addToQueue: 'Añadir a la cola',
     },
     bom: {
       title: 'Lista de materiales',
@@ -4306,7 +4262,6 @@ export default {
 
   // Print modal
   printModal: {
-    title: 'Iniciar impresión',
     selectPrinter: 'Seleccionar impresora',
     selectPlate: 'Seleccionar cama',
     filamentMapping: 'Mapeo de filamentos',
@@ -4318,15 +4273,25 @@ export default {
     vibrationCalibration: 'Calibración de vibración',
     layerInspection: 'Inspección de la primera capa',
     timelapse: 'Time-lapse',
-    startPrint: 'Iniciar impresión',
-    addToQueue: 'Añadir a la cola',
     cancel: 'Cancelar',
     noPrintersAvailable: 'No hay impresoras disponibles',
     printerBusy: 'La impresora está ocupada',
     printerOffline: 'La impresora está desconectada',
     sameTypeDifferentColor: 'Mismo tipo, color distinto',
     filamentTypeNotLoaded: 'Tipo de filamento no cargado',
+    whenToPrint: 'Cuándo imprimir',
+    asap: 'Lo antes posible',
+    queue: 'Cola',
+    schedule: 'Programar',
+    dateTime: 'Fecha y hora',
+    invalidDateTime: 'Introduzca una fecha y hora válidas',
     openCalendar: 'Abrir calendario',
+    requireManualStart: 'Requerir inicio manual',
+    requirePreviousSuccess: 'Iniciar solo si la impresión anterior se completó correctamente',
+    autoOffAfter: 'Apagar la impresora al terminar',
+    helpAsap: 'La impresión se añadirá al principio de la cola y comenzará en cuanto haya una impresora elegible inactiva.',
+    helpSchedule: 'La impresión comenzará a la hora programada si la impresora está inactiva. Si está ocupada, esperará hasta que esté disponible.',
+    helpQueue: 'La impresión se añadirá al final de la cola.',
     leftNozzle: 'I',
     rightNozzle: 'D',
     leftNozzleTooltip: 'Boquilla izquierda',

+ 14 - 49
frontend/src/i18n/locales/fr.ts

@@ -747,7 +747,6 @@ export default {
     printTime: 'Temps d\'impression',
     filamentUsed: 'Filament utilisé',
     cost: 'Coût',
-    reprint: 'Réimprimer',
     preview: 'Aperçu',
     deleteArchive: 'Supprimer l\'archive',
     deleteConfirm: 'Supprimer cette archive ?',
@@ -794,7 +793,6 @@ export default {
     },
     menu: {
       print: 'Imprimer',
-      schedule: 'Planifier',
       openInBambuStudio: 'Ouvrir dans le Slicer',
       slice: 'Découper',
       externalLink: 'Lien externe',
@@ -889,9 +887,6 @@ export default {
       noFileForReprint: 'Aucun fichier 3MF disponible — le fichier n\'a pas pu être téléchargé depuis l\'imprimante lors de l\'enregistrement',
       noPermissionEdit: 'Pas d\'autorisation de modification',
       noPermissionDelete: 'Pas d\'autorisation de suppression',
-      reprint: 'Réimprimer',
-      schedulePrint: 'Planifier',
-      schedule: 'Planifier',
       openInBambuStudio: 'Ouvrir dans le Slicer',
       openInBambuStudioToSlice: 'Ouvrir dans le Slicer pour découper',
       slice: 'Découper',
@@ -1032,18 +1027,12 @@ export default {
     },
     title: 'File d\'attente',
     subtitle: 'Gérez vos travaux d\'impression',
-    addToQueue: 'Ajouter à la file',
     // Print modal
-    print: 'Imprimer',
-    reprint: 'Réimprimer',
-    schedulePrint: 'Planifier',
     editQueueItem: 'Modifier l\'élément',
-    printToPrinters: 'Imprimer sur {{count}} imprimantes',
-    queueToPrinters: 'Ajouter à la file pour {{count}} imprimantes',
-    queueSelectedPlates: 'Ajouter {{count}} plaques à la file',
     selectAllPlates: 'Sélectionner les {{count}} plaques',
     deselectAll: 'Tout désélectionner',
     printQueued: 'Impression ajoutée à la file',
+    printQueuedWillStartWhenIdle: 'Démarrera lorsque l\'imprimante sera inactive',
     itemsQueued: '{{count}} éléments ajoutés à la file',
     sending: 'Envoi...',
     sendingProgress: 'Envoi {{current}}/{{total}}...',
@@ -1185,8 +1174,8 @@ export default {
     },
     // Actions
     actions: {
-      stopPrint: 'Arrêter',
       startPrint: 'Démarrer',
+      stopPrint: 'Arrêter',
       requeue: 'Remettre en file',
     },
     // Bulk edit
@@ -1304,35 +1293,6 @@ export default {
     },
   },
 
-  backgroundDispatch: {
-    unknownFile: 'Fichier inconnu',
-    unknownPrinter: 'Imprimante inconnue',
-    startingPrints: 'Démarrage des impressions',
-    progressSummary: '{{complete}}/{{total}} terminés • Envoyés : {{dispatched}} • En cours : {{processing}}',
-    expandDetails: 'Expand dispatch details',
-    collapseDetails: 'Collapse dispatch details',
-    dismissToast: 'Ignorer la notification d\'envoi',
-    cancelDispatchJob: 'Annuler l\'envoi',
-    cancel: 'Annuler',
-    cancelling: 'Annulation…',
-    awaitingPrinter: 'En attente de l\'imprimante…',
-    status: {
-      dispatched: 'Dispatché',
-      processing: 'Traitement',
-      completed: 'Terminé',
-      failed: 'Échoué',
-      cancelled: 'Annulé',
-    },
-    toast: {
-      cancellingUpload: 'Cancelling upload...',
-      cancelled: 'Envoi annulé',
-      cancelFailed: 'Échec de l\'annulation de l\'envoi',
-      completeWithFailures: 'Envoi en arrière-plan terminé : {{completed}} réussi(s), {{failed}} échoué(s)',
-      completeSuccess: 'Envoi en arrière-plan terminé : {{completed}} réussi(s)',
-      printStartedRemaining: '{{completed}} impression(s) lancée(s), {{remaining}} en cours d\'envoi...',
-    },
-  },
-
   // Statistics page
   stats: {
     title: 'Statistiques',
@@ -3343,8 +3303,6 @@ export default {
     changeLink: 'Modifier lien...',
     linkTo: 'Lier à...',
     linkToProjectOrArchive: 'Lier à projet ou archive',
-    addToQueue: 'Ajouter à la file',
-    schedulePrint: 'Planifier',
     generateThumbnail: 'Générer vignette',
     generateThumbnails: 'Générer vignettes',
     generateThumbnailsForMissing: 'Vignettes STL manquantes',
@@ -3647,8 +3605,6 @@ export default {
       fileCount: '{{count}} fichier(s)',
       empty: 'Aucun dossier lié.',
       noFiles: 'Aucun fichier dans ce dossier.',
-      print: 'Imprimer maintenant',
-      addToQueue: 'Ajouter à la file',
     },
     bom: {
       title: 'BOM (Liste matériel)',
@@ -4287,7 +4243,6 @@ export default {
 
   // Print modal
   printModal: {
-    title: 'Lancer l\'impression',
     selectPrinter: 'Choisir l\'imprimante',
     selectPlate: 'Choisir le plateau',
     filamentMapping: 'Mapping Filament',
@@ -4299,15 +4254,25 @@ export default {
     vibrationCalibration: 'Vibration (Input Shaper)',
     layerInspection: 'Inspection 1ère couche',
     timelapse: 'Time-lapse',
-    startPrint: 'Démarrer',
-    addToQueue: 'Ajouter à la file',
     cancel: 'Annuler',
     noPrintersAvailable: 'Aucune imprimante disponible',
     printerBusy: 'L\'imprimante est occupée',
     printerOffline: 'L\'imprimante est hors ligne',
     sameTypeDifferentColor: 'Même type, couleur différente',
     filamentTypeNotLoaded: 'Type de filament non chargé',
+    whenToPrint: 'Quand imprimer',
+    asap: 'Dès que possible',
+    queue: 'File',
+    schedule: 'Planifier',
+    dateTime: 'Date et heure',
+    invalidDateTime: 'Veuillez saisir une date et une heure valides',
     openCalendar: 'Ouvrir calendrier',
+    requireManualStart: 'Démarrage manuel requis',
+    requirePreviousSuccess: 'Démarrer seulement si l\'impression précédente a réussi',
+    autoOffAfter: 'Éteindre l\'imprimante à la fin',
+    helpAsap: 'L\'impression sera ajoutée en haut de la file et démarrera dès qu\'une imprimante éligible sera inactive.',
+    helpSchedule: 'L\'impression démarrera à l\'heure planifiée si l\'imprimante est inactive. Si elle est occupée, elle attendra qu\'elle soit disponible.',
+    helpQueue: 'L\'impression sera ajoutée à la fin de la file.',
     leftNozzle: 'G',
     rightNozzle: 'D',
     leftNozzleTooltip: 'Buse gauche',

+ 14 - 49
frontend/src/i18n/locales/it.ts

@@ -747,7 +747,6 @@ export default {
     printTime: 'Tempo di stampa',
     filamentUsed: 'Filamento usato',
     cost: 'Costo',
-    reprint: 'Ristampa',
     preview: 'Anteprima',
     deleteArchive: 'Elimina archivio',
     deleteConfirm: 'Sei sicuro di eliminare questo archivio?',
@@ -794,7 +793,6 @@ export default {
     },
     menu: {
       print: 'Stampa',
-      schedule: 'Programma',
       openInBambuStudio: 'Apri nello slicer',
       slice: 'Slice',
       externalLink: 'Link esterno',
@@ -889,9 +887,6 @@ export default {
       noFileForReprint: 'Nessun file 3MF disponibile — il file non è stato scaricato dalla stampante durante la registrazione',
       noPermissionEdit: 'Non hai il permesso di modificare archivi',
       noPermissionDelete: 'Non hai il permesso di eliminare archivi',
-      reprint: 'Ristampa',
-      schedulePrint: 'Programma Stampa',
-      schedule: 'Programma',
       openInBambuStudio: 'Apri nello slicer',
       openInBambuStudioToSlice: 'Apri nello slicer per slicing',
       slice: 'Slice',
@@ -1032,18 +1027,12 @@ export default {
     },
     title: 'Coda di stampa',
     subtitle: 'Programma e gestisci i tuoi lavori di stampa',
-    addToQueue: 'Aggiungi alla coda',
     // Print modal
-    print: 'Stampa',
-    reprint: 'Ristampa',
-    schedulePrint: 'Programma Stampa',
     editQueueItem: 'Modifica elemento coda',
-    printToPrinters: 'Stampa su {{count}} Stampanti',
-    queueToPrinters: 'Metti in coda su {{count}} Stampanti',
-    queueSelectedPlates: 'Metti in coda {{count}} piastre',
     selectAllPlates: 'Seleziona tutte le {{count}} piastre',
     deselectAll: 'Deseleziona tutto',
     printQueued: 'Stampa in coda',
+    printQueuedWillStartWhenIdle: 'Si avvierà quando la stampante sarà inattiva',
     itemsQueued: '{{count}} elementi in coda',
     sending: 'Invio...',
     sendingProgress: 'Invio {{current}}/{{total}}...',
@@ -1185,8 +1174,8 @@ export default {
     },
     // Actions
     actions: {
-      stopPrint: 'Ferma Stampa',
       startPrint: 'Avvia Stampa',
+      stopPrint: 'Ferma Stampa',
       requeue: 'Rimetti in coda',
     },
     // Bulk edit
@@ -1304,35 +1293,6 @@ 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…',
-    awaitingPrinter: 'In attesa della stampante…',
-    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',
-      printStartedRemaining: '{{completed}} stampa/e avviata/e, {{remaining}} in invio...',
-    },
-  },
-
   // Statistics page
   stats: {
     title: 'Statistiche',
@@ -3342,8 +3302,6 @@ export default {
     changeLink: 'Cambia collegamento...',
     linkTo: 'Collega a...',
     linkToProjectOrArchive: 'Collega a progetto o archivio',
-    addToQueue: 'Aggiungi alla coda',
-    schedulePrint: 'Pianifica',
     generateThumbnail: 'Genera miniatura',
     generateThumbnails: 'Genera miniature',
     generateThumbnailsForMissing: 'Genera miniature per STL senza miniatura',
@@ -3646,8 +3604,6 @@ export default {
       fileCount: '{{count}} file',
       empty: 'Nessuna cartella collegata. Vai a Gestore file e collega una cartella a questo progetto.',
       noFiles: 'Nessun file in questa cartella.',
-      print: 'Stampa ora',
-      addToQueue: 'Aggiungi alla coda',
     },
     bom: {
       title: 'Distinta materiali',
@@ -4286,7 +4242,6 @@ export default {
 
   // Print modal
   printModal: {
-    title: 'Avvia stampa',
     selectPrinter: 'Seleziona stampante',
     selectPlate: 'Seleziona piatto',
     filamentMapping: 'Mappatura filamento',
@@ -4298,15 +4253,25 @@ export default {
     vibrationCalibration: 'Calibrazione vibrazioni',
     layerInspection: 'Ispezione primo layer',
     timelapse: 'Timelapse',
-    startPrint: 'Avvia stampa',
-    addToQueue: 'Aggiungi alla coda',
     cancel: 'Annulla',
     noPrintersAvailable: 'Nessuna stampante disponibile',
     printerBusy: 'Stampante occupata',
     printerOffline: 'Stampante offline',
     sameTypeDifferentColor: 'Stesso tipo, colore diverso',
     filamentTypeNotLoaded: 'Tipo di filamento non caricato',
+    whenToPrint: 'Quando stampare',
+    asap: 'Subito',
+    queue: 'Coda',
+    schedule: 'Programma',
+    dateTime: 'Data e ora',
+    invalidDateTime: 'Inserisci data e ora valide',
     openCalendar: 'Apri calendario',
+    requireManualStart: 'Richiedi avvio manuale',
+    requirePreviousSuccess: 'Avvia solo se la stampa precedente è riuscita',
+    autoOffAfter: 'Spegni la stampante al termine',
+    helpAsap: 'La stampa verrà aggiunta in cima alla coda e partirà appena una stampante idonea è inattiva.',
+    helpSchedule: 'La stampa partirà all\'ora programmata se la stampante è inattiva. Se è occupata, attenderà che diventi disponibile.',
+    helpQueue: 'La stampa verrà aggiunta in fondo alla coda.',
     leftNozzle: 'L',
     rightNozzle: 'R',
     leftNozzleTooltip: 'Ugello sinistro',

+ 14 - 49
frontend/src/i18n/locales/ja.ts

@@ -746,7 +746,6 @@ export default {
     printTime: '印刷時間',
     filamentUsed: 'フィラメント使用量',
     cost: 'コスト',
-    reprint: '再印刷',
     preview: 'プレビュー',
     deleteArchive: 'アーカイブを削除',
     deleteConfirm: 'このアーカイブを削除しますか?',
@@ -793,7 +792,6 @@ export default {
     },
     menu: {
       print: '印刷',
-      schedule: 'スケジュール',
       openInBambuStudio: 'スライサーで開く',
       slice: 'スライス',
       externalLink: '外部リンク',
@@ -888,9 +886,6 @@ export default {
       noFileForReprint: '3MFファイルがありません — 印刷記録時にプリンターからファイルをダウンロードできませんでした',
       noPermissionEdit: 'プロファイルを編集する権限がありません',
       noPermissionDelete: 'アーカイブを削除する権限がありません',
-      reprint: '再印刷',
-      schedulePrint: '印刷をスケジュール',
-      schedule: 'スケジュール',
       openInBambuStudio: 'スライサーで開く',
       openInBambuStudioToSlice: 'スライサーでスライス',
       slice: 'スライス',
@@ -1031,18 +1026,12 @@ export default {
     },
     title: '印刷キュー',
     subtitle: '印刷ジョブのスケジュールと管理',
-    addToQueue: 'キューに追加',
     // Print modal
-    print: '印刷',
-    reprint: '再印刷',
-    schedulePrint: '印刷をスケジュール',
     editQueueItem: 'キューアイテムを編集',
-    printToPrinters: '{{count}}台のプリンターで印刷',
-    queueToPrinters: '{{count}}台のプリンターでキュー追加',
-    queueSelectedPlates: '{{count}}プレートをキューに追加',
     selectAllPlates: '全{{count}}プレートを選択',
     deselectAll: '全て解除',
     printQueued: 'キューに追加しました',
+    printQueuedWillStartWhenIdle: 'プリンターがアイドル状態になると開始します',
     itemsQueued: '{{count}}件をキューに追加しました',
     sending: '送信中...',
     sendingProgress: '送信中 {{current}}/{{total}}...',
@@ -1184,8 +1173,8 @@ export default {
     },
     // Actions
     actions: {
-      stopPrint: '印刷を停止',
       startPrint: '印刷を開始',
+      stopPrint: '印刷を停止',
       requeue: '再キュー',
     },
     // Bulk edit
@@ -1303,35 +1292,6 @@ export default {
     },
   },
 
-  backgroundDispatch: {
-    unknownFile: '不明なファイル',
-    unknownPrinter: '不明なプリンター',
-    startingPrints: '印刷開始中',
-    progressSummary: '{{complete}}/{{total}} 完了 • 配信済み: {{dispatched}} • 処理中: {{processing}}',
-    expandDetails: '配信詳細を展開',
-    collapseDetails: '配信詳細を折りたたむ',
-    dismissToast: '配信トーストを閉じる',
-    cancelDispatchJob: '配信ジョブをキャンセル',
-    cancel: 'キャンセル',
-    cancelling: 'キャンセル中…',
-    awaitingPrinter: 'プリンターを待機中…',
-    status: {
-      dispatched: '配信済み',
-      processing: '処理中',
-      completed: '完了',
-      failed: '失敗',
-      cancelled: 'キャンセル済み',
-    },
-    toast: {
-      cancellingUpload: 'アップロードをキャンセル中...',
-      cancelled: '配信をキャンセルしました',
-      cancelFailed: '配信のキャンセルに失敗しました',
-      completeWithFailures: 'バックグラウンド配信完了: {{completed}} 件成功、{{failed}} 件失敗',
-      completeSuccess: 'バックグラウンド配信完了: {{completed}} 件成功',
-      printStartedRemaining: '{{completed}} 件の印刷を開始、残り {{remaining}} 件送信中...',
-    },
-  },
-
   // Statistics page
   stats: {
     title: '統計',
@@ -3354,8 +3314,6 @@ export default {
     changeLink: 'リンクを変更...',
     linkTo: 'リンク先...',
     linkToProjectOrArchive: 'プロジェクトまたはアーカイブにリンク',
-    addToQueue: 'キューに追加',
-    schedulePrint: '印刷をスケジュール',
     generateThumbnail: 'サムネイルを生成',
     generateThumbnails: 'サムネイルを生成',
     generateThumbnailsForMissing: 'サムネイルのないSTLファイルのサムネイルを生成',
@@ -3658,8 +3616,6 @@ export default {
       fileCount: '{{count}}ファイル',
       empty: '<空>',
       noFiles: 'このフォルダにファイルはありません。',
-      print: '今すぐ印刷',
-      addToQueue: 'キューに追加',
     },
     bom: {
       title: '部品表',
@@ -4298,7 +4254,6 @@ export default {
 
   // Print modal
   printModal: {
-    title: '印刷を開始',
     selectPrinter: 'プリンターを選択',
     selectPlate: 'プレートを選択',
     filamentMapping: 'フィラメントマッピング',
@@ -4310,15 +4265,25 @@ export default {
     vibrationCalibration: '振動キャリブレーション',
     layerInspection: '第一層検査',
     timelapse: 'タイムラプス',
-    startPrint: '印刷を開始',
-    addToQueue: 'キューに追加',
     cancel: 'キャンセル',
     noPrintersAvailable: '利用可能なプリンターがありません',
     printerBusy: 'プリンターは使用中です',
     printerOffline: 'プリンターはオフラインです',
     sameTypeDifferentColor: '同じ種類、異なる色',
     filamentTypeNotLoaded: 'フィラメントタイプが未読み込み',
+    whenToPrint: '印刷タイミング',
+    asap: '即時',
+    queue: 'キュー',
+    schedule: 'スケジュール',
+    dateTime: '日時',
+    invalidDateTime: '有効な日時を入力してください',
     openCalendar: 'カレンダーを開く',
+    requireManualStart: '手動開始を要求',
+    requirePreviousSuccess: '前の印刷が成功した場合のみ開始',
+    autoOffAfter: '完了後にプリンターの電源を切る',
+    helpAsap: '印刷はキューの先頭に追加され、対象プリンターがアイドルになるとすぐに開始します。',
+    helpSchedule: '予定時刻にプリンターがアイドルであれば印刷を開始します。使用中の場合は、利用可能になるまで待機します。',
+    helpQueue: '印刷はキューの最後に追加されます。',
     leftNozzle: 'L',
     rightNozzle: 'R',
     leftNozzleTooltip: '左ノズル',

+ 15 - 49
frontend/src/i18n/locales/ko.ts

@@ -704,7 +704,6 @@ export default {
     printTime: '인쇄 시간',
     filamentUsed: '사용된 필라멘트',
     cost: '비용',
-    reprint: '재인쇄',
     preview: '미리보기',
     deleteArchive: '아카이브 삭제',
     deleteConfirm: '이 아카이브를 삭제하시겠습니까?',
@@ -751,7 +750,6 @@ export default {
     },
     menu: {
       print: '인쇄',
-      schedule: '예약',
       openInBambuStudio: '슬라이서에서 열기',
       slice: '슬라이스',
       externalLink: '외부 링크',
@@ -844,9 +842,6 @@ export default {
       noFileForReprint: '3MF 파일 없음 — 인쇄 기록 시 프린터에서 파일을 다운로드할 수 없었습니다',
       noPermissionEdit: '아카이브를 편집할 권한이 없습니다',
       noPermissionDelete: '아카이브를 삭제할 권한이 없습니다',
-      reprint: '재인쇄',
-      schedulePrint: '인쇄 예약',
-      schedule: '예약',
       openInBambuStudio: '슬라이서에서 열기',
       openInBambuStudioToSlice: '슬라이스하려면 슬라이서에서 열기',
       slice: '슬라이스',
@@ -978,17 +973,11 @@ export default {
   queue: {
     title: '인쇄 대기열',
     subtitle: '인쇄 작업을 예약하고 관리하세요',
-    addToQueue: '대기열에 추가',
-    print: '인쇄',
-    reprint: '재인쇄',
-    schedulePrint: '인쇄 예약',
     editQueueItem: '대기열 항목 편집',
-    printToPrinters: '{{count}}개 프린터에 인쇄',
-    queueToPrinters: '{{count}}개 프린터에 대기',
-    queueSelectedPlates: '{{count}}개 플레이트 대기',
     selectAllPlates: '{{count}}개 플레이트 전체 선택',
     deselectAll: '전체 해제',
     printQueued: '인쇄가 대기열에 추가됨',
+    printQueuedWillStartWhenIdle: '프린터가 유휴 상태가 되면 시작됩니다',
     itemsQueued: '{{count}}개 항목이 대기열에 추가됨',
     sending: '전송 중...',
     sendingProgress: '{{current}}/{{total}} 전송 중...',
@@ -1120,8 +1109,8 @@ export default {
       inHours: '{{count}}시간 후'
     },
     actions: {
-      stopPrint: '인쇄 정지',
       startPrint: '인쇄 시작',
+      stopPrint: '인쇄 정지',
       requeue: '재대기'
     },
     bulkEdit: {
@@ -1242,34 +1231,6 @@ export default {
       printAnyway: '그냥 인쇄'
     }
   },
-  backgroundDispatch: {
-    unknownFile: '알 수 없는 파일',
-    unknownPrinter: '알 수 없는 프린터',
-    startingPrints: '인쇄 시작 중',
-    progressSummary: '{{complete}}/{{total}} 완료 • 전송됨: {{dispatched}} • 처리 중: {{processing}}',
-    expandDetails: '전송 상세 펼치기',
-    collapseDetails: '전송 상세 접기',
-    dismissToast: '전송 알림 닫기',
-    cancelDispatchJob: '전송 작업 취소',
-    cancel: '취소',
-    cancelling: '취소 중…',
-    awaitingPrinter: '프린터 대기 중…',
-    status: {
-      dispatched: '전송됨',
-      processing: '처리 중',
-      completed: '완료',
-      failed: '실패',
-      cancelled: '취소됨'
-    },
-    toast: {
-      cancellingUpload: '업로드 취소 중...',
-      cancelled: '전송이 취소되었습니다',
-      cancelFailed: '전송 취소 실패',
-      completeWithFailures: '백그라운드 전송 완료: {{completed}}개 성공, {{failed}}개 실패',
-      completeSuccess: '백그라운드 전송 완료: {{completed}}개 성공',
-      printStartedRemaining: '{{completed}}개 인쇄 시작, {{remaining}}개 더 전송 중...'
-    }
-  },
   stats: {
     title: '대시보드',
     subtitle: '위젯을 드래그하여 정렬하세요. 눈 아이콘을 클릭하여 숨기세요.',
@@ -3167,8 +3128,6 @@ export default {
     changeLink: '링크 변경...',
     linkTo: '연결 대상...',
     linkToProjectOrArchive: '프로젝트 또는 아카이브에 연결',
-    addToQueue: '대기열에 추가',
-    schedulePrint: '예약',
     generateThumbnail: '썸네일 생성',
     generateThumbnails: '썸네일 생성',
     generateThumbnailsForMissing: '썸네일이 없는 STL 파일의 썸네일 생성',
@@ -3459,9 +3418,7 @@ export default {
       forQuickAccess: '빠른 접근을 위해 이 프로젝트에 연결합니다.',
       fileCount: '{{count}}개 파일',
       empty: '연결된 폴더가 없습니다. 파일 관리자로 이동하여 폴더를 이 프로젝트에 연결하세요.',
-      noFiles: '이 폴더에 파일이 없습니다.',
-      print: '지금 인쇄',
-      addToQueue: '대기열에 추가'
+      noFiles: '이 폴더에 파일이 없습니다.'
     },
     bom: {
       title: '부품 목록',
@@ -4071,7 +4028,6 @@ export default {
     emptySlotReset: '필라멘트가 할당되지 않음'
   },
   printModal: {
-    title: '인쇄 시작',
     selectPrinter: '프린터 선택',
     selectPlate: '플레이트 선택',
     filamentMapping: '필라멘트 매핑',
@@ -4083,15 +4039,25 @@ export default {
     vibrationCalibration: '진동 보정',
     layerInspection: '첫 번째 레이어 검사',
     timelapse: '타임랩스',
-    startPrint: '인쇄 시작',
-    addToQueue: '대기열에 추가',
     cancel: '취소',
     noPrintersAvailable: '사용 가능한 프린터 없음',
     printerBusy: '프린터가 사용 중입니다',
     printerOffline: '프린터가 오프라인입니다',
     sameTypeDifferentColor: '같은 유형, 다른 색상',
     filamentTypeNotLoaded: '필라멘트 유형이 장착되지 않음',
+    whenToPrint: '인쇄 시점',
+    asap: '최대한 빨리',
+    queue: '대기열',
+    schedule: '예약',
+    dateTime: '날짜 및 시간',
+    invalidDateTime: '유효한 날짜와 시간을 입력하세요',
     openCalendar: '달력 열기',
+    requireManualStart: '수동 시작 필요',
+    requirePreviousSuccess: '이전 인쇄가 성공한 경우에만 시작',
+    autoOffAfter: '완료 후 프린터 전원 끄기',
+    helpAsap: '인쇄가 대기열 맨 앞에 추가되고 적합한 프린터가 유휴 상태가 되는 즉시 시작됩니다.',
+    helpSchedule: '프린터가 유휴 상태이면 예약된 시간에 인쇄가 시작됩니다. 사용 중이면 프린터가 사용 가능해질 때까지 대기합니다.',
+    helpQueue: '인쇄가 대기열 맨 뒤에 추가됩니다.',
     leftNozzle: 'L',
     rightNozzle: 'R',
     leftNozzleTooltip: '왼쪽 노즐',

+ 14 - 49
frontend/src/i18n/locales/pt-BR.ts

@@ -747,7 +747,6 @@ export default {
     printTime: 'Tempo de impressão',
     filamentUsed: 'Filamento usado',
     cost: 'Custo',
-    reprint: 'Reimprimir',
     preview: 'Pré-visualizar',
     deleteArchive: 'Excluir arquivo',
     deleteConfirm: 'Tem certeza de que deseja excluir este arquivo?',
@@ -794,7 +793,6 @@ export default {
     },
     menu: {
       print: 'Imprimir',
-      schedule: 'Agendar',
       openInBambuStudio: 'Abrir no Slicer',
       slice: 'Fatiar',
       externalLink: 'Link externo',
@@ -889,9 +887,6 @@ export default {
       noFileForReprint: 'Nenhum arquivo 3MF disponível — o arquivo não pôde ser baixado da impressora quando a impressão foi registrada',
       noPermissionEdit: 'Você não tem permissão para editar arquivos',
       noPermissionDelete: 'Você não tem permissão para excluir arquivos',
-      reprint: 'Reimprimir',
-      schedulePrint: 'Agendar impressão',
-      schedule: 'Agendar',
       openInBambuStudio: 'Abrir no Bambu Studio',
       openInBambuStudioToSlice: 'Abrir no Bambu Studio para fatiar',
       slice: 'Fatiar',
@@ -1032,18 +1027,12 @@ export default {
     },
     title: 'Fila de Impressão',
     subtitle: 'Agende e gerencie seus trabalhos de impressão',
-    addToQueue: 'Adicionar à Fila',
     // Print modal
-    print: 'Imprimir',
-    reprint: 'Reimprimir',
-    schedulePrint: 'Agendar Impressão',
     editQueueItem: 'Editar Item da Fila',
-    printToPrinters: 'Imprimir para {{count}} Impressoras',
-    queueToPrinters: 'Adicionar à Fila para {{count}} Impressoras',
-    queueSelectedPlates: 'Adicionar {{count}} placas à fila',
     selectAllPlates: 'Selecionar todas as {{count}} placas',
     deselectAll: 'Desmarcar tudo',
     printQueued: 'Impressão adicionada à fila',
+    printQueuedWillStartWhenIdle: 'Iniciará quando a impressora estiver ociosa',
     itemsQueued: '{{count}} itens adicionados à fila',
     sending: 'Enviando...',
     sendingProgress: 'Enviando {{current}}/{{total}}...',
@@ -1185,8 +1174,8 @@ export default {
     },
     // Actions
     actions: {
-      stopPrint: 'Parar Impressão',
       startPrint: 'Iniciar Impressão',
+      stopPrint: 'Parar Impressão',
       requeue: 'Reenfileirar',
     },
     // Bulk edit
@@ -1304,35 +1293,6 @@ export default {
     },
   },
 
-  backgroundDispatch: {
-    unknownFile: 'Arquivo desconhecido',
-    unknownPrinter: 'Impressora desconhecida',
-    startingPrints: 'Iniciando impressões',
-    progressSummary: '{{complete}}/{{total}} concluídos • Despachados: {{dispatched}} • Processando: {{processing}}',
-    expandDetails: 'Expand dispatch details',
-    collapseDetails: 'Collapse dispatch details',
-    dismissToast: 'Dispensar notificação de despacho',
-    cancelDispatchJob: 'Cancelar despacho',
-    cancel: 'Cancelar',
-    cancelling: 'Cancelando…',
-    awaitingPrinter: 'Aguardando impressora…',
-    status: {
-      dispatched: 'Despachado',
-      processing: 'Processando',
-      completed: 'Concluído',
-      failed: 'Falhou',
-      cancelled: 'Cancelado',
-    },
-    toast: {
-      cancellingUpload: 'Cancelling upload...',
-      cancelled: 'Despacho cancelado',
-      cancelFailed: 'Falha ao cancelar despacho',
-      completeWithFailures: 'Despacho em segundo plano concluído: {{completed}} sucesso(s), {{failed}} falha(s)',
-      completeSuccess: 'Despacho em segundo plano concluído: {{completed}} sucesso(s)',
-      printStartedRemaining: '{{completed}} impressão(ões) iniciada(s), {{remaining}} enviando...',
-    },
-  },
-
   // Statistics page
   stats: {
     title: 'Estatísticas',
@@ -3342,8 +3302,6 @@ export default {
     changeLink: 'Alterar link...',
     linkTo: 'Vincular a...',
     linkToProjectOrArchive: 'Vincular a projeto ou arquivo',
-    addToQueue: 'Adicionar à fila',
-    schedulePrint: 'Agendar impressão',
     generateThumbnail: 'Gerar miniatura',
     generateThumbnails: 'Gerar miniaturas',
     generateThumbnailsForMissing: 'Gerar miniaturas para arquivos STL que não possuem',
@@ -3646,8 +3604,6 @@ export default {
       fileCount: '{{count}} arquivo(s)',
       empty: 'Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.',
       noFiles: 'Nenhum arquivo nesta pasta.',
-      print: 'Imprimir agora',
-      addToQueue: 'Adicionar à fila',
     },
     bom: {
       title: 'Lista de Materiais',
@@ -4286,7 +4242,6 @@ export default {
 
   // Print modal
   printModal: {
-    title: 'Iniciar Impressão',
     selectPrinter: 'Selecionar Impressora',
     selectPlate: 'Selecionar Placa',
     filamentMapping: 'Mapeamento de Filamento',
@@ -4298,15 +4253,25 @@ export default {
     vibrationCalibration: 'Calibração de Vibração',
     layerInspection: 'Inspeção da Primeira Camada',
     timelapse: 'Timelapse',
-    startPrint: 'Iniciar Impressão',
-    addToQueue: 'Adicionar à Fila',
     cancel: 'Cancelar',
     noPrintersAvailable: 'Nenhuma impressora disponível',
     printerBusy: 'Impressora ocupada',
     printerOffline: 'Impressora offline',
     sameTypeDifferentColor: 'Mesmo tipo, cor diferente',
     filamentTypeNotLoaded: 'Tipo de filamento não carregado',
+    whenToPrint: 'Quando imprimir',
+    asap: 'O quanto antes',
+    queue: 'Fila',
+    schedule: 'Agendar',
+    dateTime: 'Data e hora',
+    invalidDateTime: 'Digite uma data e hora válidas',
     openCalendar: 'Abrir calendário',
+    requireManualStart: 'Exigir início manual',
+    requirePreviousSuccess: 'Iniciar somente se a impressão anterior foi concluída com sucesso',
+    autoOffAfter: 'Desligar impressora ao finalizar',
+    helpAsap: 'A impressão será adicionada ao topo da fila e começará assim que uma impressora elegível estiver ociosa.',
+    helpSchedule: 'A impressão começará no horário agendado se a impressora estiver ociosa. Se estiver ocupada, aguardará até ficar disponível.',
+    helpQueue: 'A impressão será adicionada ao fim da fila.',
     leftNozzle: 'L',
     rightNozzle: 'R',
     leftNozzleTooltip: 'Bico esquerdo',

+ 14 - 48
frontend/src/i18n/locales/tr.ts

@@ -747,7 +747,6 @@ export default {
     printTime: 'Baskı Süresi',
     filamentUsed: 'Kullanılan Filament',
     cost: 'Maliyet',
-    reprint: 'Tekrar Yazdır',
     preview: 'Önizleme',
     deleteArchive: 'Arşivi Sil',
     deleteConfirm: 'Bu arşivi silmek istediğinizden emin misiniz?',
@@ -794,7 +793,6 @@ export default {
     },
     menu: {
       print: 'Yazdır',
-      schedule: 'Zamanla',
       openInBambuStudio: 'Dilimleyicide Aç',
       slice: 'Dilimle',
       externalLink: 'Harici Bağlantı',
@@ -889,9 +887,6 @@ export default {
       noFileForReprint: 'Kullanılabilir 3MF dosyası yok — baskı kaydedildiğinde dosya yazıcıdan indirilemedi',
       noPermissionEdit: 'Arşivleri düzenleme izniniz yok',
       noPermissionDelete: 'Arşivleri silme izniniz yok',
-      reprint: 'Tekrar Yazdır',
-      schedulePrint: 'Baskıyı Zamanla',
-      schedule: 'Zamanla',
       openInBambuStudio: 'Dilimleyicide Aç',
       openInBambuStudioToSlice: 'Dilimlemek için Dilimleyicide Aç',
       slice: 'Dilimle',
@@ -1023,7 +1018,6 @@ export default {
   queue: {
     title: 'Baskı Kuyruğu',
     subtitle: 'Baskı işlerinizi zamanlayın ve yönetin',
-    addToQueue: 'Kuyruğa Ekle',
     filamentShort: {
       rowBadge: 'Atanan makara için yetersiz filament',
       rowTooltip: 'Sevk planlayıcı bu öğeyi işaretledi. Yuva başına eksikliği görmek ve yine de yazdırıp yazdırmayacağınıza karar vermek için Oynat\'a tıklayın.',
@@ -1034,16 +1028,11 @@ export default {
       printAnyway: 'Yine de Yazdır',
     },
     // Baskı modali
-    print: 'Yazdır',
-    reprint: 'Tekrar Yazdır',
-    schedulePrint: 'Baskıyı Zamanla',
     editQueueItem: 'Kuyruk Öğesini Düzenle',
-    printToPrinters: '{{count}} Yazıcıya Yazdır',
-    queueToPrinters: '{{count}} Yazıcıya Kuyrukla',
-    queueSelectedPlates: '{{count}} Plakayı Kuyrukla',
     selectAllPlates: 'Tüm {{count}} Plakayı Seç',
     deselectAll: 'Seçimi Kaldır',
     printQueued: 'Baskı kuyruğa eklendi',
+    printQueuedWillStartWhenIdle: 'Yazıcı boştayken başlayacak',
     itemsQueued: '{{count}} öğe kuyruğa eklendi',
     sending: 'Gönderiliyor...',
     sendingProgress: 'Gönderiliyor {{current}}/{{total}}...',
@@ -1185,8 +1174,8 @@ export default {
     },
     // İşlemler
     actions: {
-      stopPrint: 'Baskıyı Durdur',
       startPrint: 'Baskıyı Başlat',
+      stopPrint: 'Baskıyı Durdur',
       requeue: 'Yeniden Kuyrukla',
     },
     // Toplu düzenleme
@@ -1304,34 +1293,6 @@ export default {
     },
   },
 
-  backgroundDispatch: {
-    unknownFile: 'Bilinmeyen dosya',
-    unknownPrinter: 'Bilinmeyen yazıcı',
-    startingPrints: 'Baskılar başlatılıyor',
-    progressSummary: '{{complete}}/{{total}} tamamlandı • Sevk edildi: {{dispatched}} • İşleniyor: {{processing}}',
-    expandDetails: 'Sevk ayrıntılarını genişlet',
-    collapseDetails: 'Sevk ayrıntılarını daralt',
-    dismissToast: 'Sevk bildirimini kapat',
-    cancelDispatchJob: 'Sevk işini iptal et',
-    cancel: 'İptal',
-    cancelling: 'İptal ediliyor…',
-    awaitingPrinter: 'Yazıcı bekleniyor…',
-    status: {
-      dispatched: 'Sevk Edildi',
-      processing: 'İşleniyor',
-      completed: 'Tamamlandı',
-      failed: 'Başarısız',
-      cancelled: 'İptal edildi',
-    },
-    toast: {
-      cancellingUpload: 'Yükleme iptal ediliyor...',
-      cancelled: 'Sevk iptal edildi',
-      cancelFailed: 'Sevk iptal edilemedi',
-      completeWithFailures: 'Arka plan sevki tamamlandı: {{completed}} başarılı, {{failed}} başarısız',
-      completeSuccess: 'Arka plan sevki tamamlandı: {{completed}} başarılı',
-      printStartedRemaining: '{{completed}} baskı başlatıldı, {{remaining}} tane daha gönderiliyor...',
-    },
-  },
 
   // İstatistikler sayfası
   stats: {
@@ -3349,8 +3310,6 @@ export default {
     changeLink: 'Bağlantıyı Değiştir...',
     linkTo: 'Şuna bağla...',
     linkToProjectOrArchive: 'Projeye veya arşive bağla',
-    addToQueue: 'Kuyruğa Ekle',
-    schedulePrint: 'Zamanla',
     generateThumbnail: 'Küçük Resim Oluştur',
     generateThumbnails: 'Küçük Resimler Oluştur',
     generateThumbnailsForMissing: 'Eksik olan STL dosyaları için küçük resimler oluştur',
@@ -3647,8 +3606,6 @@ export default {
       fileCount: '{{count}} dosya',
       empty: 'Bağlı klasör yok. Dosya Yöneticisine gidin ve bu projeye bir klasör bağlayın.',
       noFiles: 'Bu klasörde dosya yok.',
-      print: 'Şimdi Yazdır',
-      addToQueue: 'Kuyruğa Ekle',
     },
     bom: {
       title: 'Malzeme Listesi',
@@ -4275,7 +4232,6 @@ export default {
 
   // Baskı modali
   printModal: {
-    title: 'Baskıyı Başlat',
     selectPrinter: 'Yazıcı Seç',
     selectPlate: 'Plaka Seç',
     filamentMapping: 'Filament Eşlemesi',
@@ -4287,15 +4243,25 @@ export default {
     vibrationCalibration: 'Titreşim Kalibrasyonu',
     layerInspection: 'İlk Katman Denetimi',
     timelapse: 'Zaman Atlamalı Video',
-    startPrint: 'Baskıyı Başlat',
-    addToQueue: 'Kuyruğa Ekle',
     cancel: 'İptal',
     noPrintersAvailable: 'Kullanılabilir yazıcı yok',
     printerBusy: 'Yazıcı meşgul',
     printerOffline: 'Yazıcı çevrimdışı',
     sameTypeDifferentColor: 'Aynı tür, farklı renk',
     filamentTypeNotLoaded: 'Filament türü yüklenmedi',
+    whenToPrint: 'Ne zaman yazdırılsın',
+    asap: 'En kısa sürede',
+    queue: 'Kuyruk',
+    schedule: 'Zamanla',
+    dateTime: 'Tarih ve saat',
+    invalidDateTime: 'Lütfen geçerli bir tarih ve saat girin',
     openCalendar: 'Takvimi aç',
+    requireManualStart: 'Manuel başlatma gerektir',
+    requirePreviousSuccess: 'Yalnızca önceki baskı başarılıysa başlat',
+    autoOffAfter: 'Bittiğinde yazıcıyı kapat',
+    helpAsap: 'Baskı kuyruğun en üstüne eklenir ve uygun bir yazıcı boştaysa başlar.',
+    helpSchedule: 'Yazıcı boşta ise baskı zamanlanan saatte başlar. Meşgulse yazıcı uygun olana kadar bekler.',
+    helpQueue: 'Baskı kuyruğun sonuna eklenir.',
     leftNozzle: 'S',
     rightNozzle: 'R',
     leftNozzleTooltip: 'Sol nozul',

+ 14 - 49
frontend/src/i18n/locales/zh-CN.ts

@@ -747,7 +747,6 @@ export default {
     printTime: '打印时间',
     filamentUsed: '耗材用量',
     cost: '成本',
-    reprint: '重新打印',
     preview: '预览',
     deleteArchive: '删除归档',
     deleteConfirm: '确定要删除此归档吗?',
@@ -794,7 +793,6 @@ export default {
     },
     menu: {
       print: '打印',
-      schedule: '排程',
       openInBambuStudio: '在切片软件中打开',
       slice: '切片',
       externalLink: '外部链接',
@@ -889,9 +887,6 @@ export default {
       noFileForReprint: '无可用的 3MF 文件 — 打印记录时无法从打印机下载该文件',
       noPermissionEdit: '您没有编辑归档的权限',
       noPermissionDelete: '您没有删除归档的权限',
-      reprint: '重新打印',
-      schedulePrint: '排程打印',
-      schedule: '排程',
       openInBambuStudio: '在切片软件中打开',
       openInBambuStudioToSlice: '在切片软件中打开进行切片',
       slice: '切片',
@@ -1032,18 +1027,12 @@ export default {
     },
     title: '打印队列',
     subtitle: '排程和管理您的打印任务',
-    addToQueue: '添加到队列',
     // Print modal
-    print: '打印',
-    reprint: '重新打印',
-    schedulePrint: '排程打印',
     editQueueItem: '编辑队列项目',
-    printToPrinters: '打印到 {{count}} 台打印机',
-    queueToPrinters: '排队到 {{count}} 台打印机',
-    queueSelectedPlates: '将 {{count}} 个热床加入队列',
     selectAllPlates: '选择全部 {{count}} 个热床',
     deselectAll: '取消全选',
     printQueued: '已加入打印队列',
+    printQueuedWillStartWhenIdle: '打印机空闲时将开始',
     itemsQueued: '{{count}} 个任务已加入队列',
     sending: '发送中...',
     sendingProgress: '发送中 {{current}}/{{total}}...',
@@ -1185,8 +1174,8 @@ export default {
     },
     // Actions
     actions: {
-      stopPrint: '停止打印',
       startPrint: '开始打印',
+      stopPrint: '停止打印',
       requeue: '重新排队',
     },
     // Bulk edit
@@ -1304,35 +1293,6 @@ export default {
     },
   },
 
-  backgroundDispatch: {
-    unknownFile: '未知文件',
-    unknownPrinter: '未知打印机',
-    startingPrints: '正在开始打印',
-    progressSummary: '{{complete}}/{{total}} 完成 • 已分发:{{dispatched}} • 处理中:{{processing}}',
-    expandDetails: '展开分发详情',
-    collapseDetails: '收起分发详情',
-    dismissToast: '关闭分发通知',
-    cancelDispatchJob: '取消分发任务',
-    cancel: '取消',
-    cancelling: '取消中…',
-    awaitingPrinter: '等待打印机…',
-    status: {
-      dispatched: '已分发',
-      processing: '处理中',
-      completed: '已完成',
-      failed: '失败',
-      cancelled: '已取消',
-    },
-    toast: {
-      cancellingUpload: '取消上传中...',
-      cancelled: '分发已取消',
-      cancelFailed: '取消分发失败',
-      completeWithFailures: '后台分发完成:{{completed}} 成功,{{failed}} 失败',
-      completeSuccess: '后台分发完成:{{completed}} 成功',
-      printStartedRemaining: '{{completed}} 个打印已开始,{{remaining}} 个正在发送...',
-    },
-  },
-
   // Statistics page
   stats: {
     title: '统计',
@@ -3342,8 +3302,6 @@ export default {
     changeLink: '更改链接...',
     linkTo: '链接到...',
     linkToProjectOrArchive: '链接到项目或归档',
-    addToQueue: '添加到队列',
-    schedulePrint: '排程',
     generateThumbnail: '生成缩略图',
     generateThumbnails: '生成缩略图',
     generateThumbnailsForMissing: '为缺少缩略图的 STL 文件生成缩略图',
@@ -3646,8 +3604,6 @@ export default {
       fileCount: '{{count}} 个文件',
       empty: '未链接文件夹。前往文件管理器将文件夹链接到此项目。',
       noFiles: '此文件夹中没有文件。',
-      print: '立即打印',
-      addToQueue: '加入队列',
     },
     bom: {
       title: '材料清单',
@@ -4286,7 +4242,6 @@ export default {
 
   // Print modal
   printModal: {
-    title: '开始打印',
     selectPrinter: '选择打印机',
     selectPlate: '选择板',
     filamentMapping: '耗材映射',
@@ -4298,15 +4253,25 @@ export default {
     vibrationCalibration: '振动校准',
     layerInspection: '首层检查',
     timelapse: '延时摄影',
-    startPrint: '开始打印',
-    addToQueue: '添加到队列',
     cancel: '取消',
     noPrintersAvailable: '无可用打印机',
     printerBusy: '打印机忙碌',
     printerOffline: '打印机离线',
     sameTypeDifferentColor: '相同类型,不同颜色',
     filamentTypeNotLoaded: '耗材类型未装载',
+    whenToPrint: '何时打印',
+    asap: '尽快',
+    queue: '队列',
+    schedule: '计划',
+    dateTime: '日期和时间',
+    invalidDateTime: '请输入有效的日期和时间',
     openCalendar: '打开日历',
+    requireManualStart: '要求手动开始',
+    requirePreviousSuccess: '仅在上一次打印成功后开始',
+    autoOffAfter: '完成后关闭打印机',
+    helpAsap: '打印将添加到队列顶部,并在符合条件的打印机空闲后立即开始。',
+    helpSchedule: '如果打印机空闲,打印将在计划时间开始。如果打印机忙碌,则会等待其可用。',
+    helpQueue: '打印将添加到队列末尾。',
     leftNozzle: '左',
     rightNozzle: '右',
     leftNozzleTooltip: '左喷嘴',

+ 14 - 49
frontend/src/i18n/locales/zh-TW.ts

@@ -747,7 +747,6 @@ export default {
     printTime: '列印時間',
     filamentUsed: '耗材用量',
     cost: '成本',
-    reprint: '重新列印',
     preview: '預覽',
     deleteArchive: '刪除歸檔',
     deleteConfirm: '確定要刪除此歸檔嗎?',
@@ -794,7 +793,6 @@ export default {
     },
     menu: {
       print: '列印',
-      schedule: '排程',
       openInBambuStudio: '在切片軟體中開啟',
       slice: '切片',
       externalLink: '外部連結',
@@ -889,9 +887,6 @@ export default {
       noFileForReprint: '無可用的 3MF 檔案 — 列印紀錄時無法從印表機下載該檔案',
       noPermissionEdit: '您沒有編輯歸檔的權限',
       noPermissionDelete: '您沒有刪除歸檔的權限',
-      reprint: '重新列印',
-      schedulePrint: '排程列印',
-      schedule: '排程',
       openInBambuStudio: '在切片軟體中開啟',
       openInBambuStudioToSlice: '在切片軟體中開啟進行切片',
       slice: '切片',
@@ -1032,18 +1027,12 @@ export default {
     },
     title: '列印佇列',
     subtitle: '排程和管理您的列印任務',
-    addToQueue: '新增到佇列',
     // Print modal
-    print: '列印',
-    reprint: '重新列印',
-    schedulePrint: '排程列印',
     editQueueItem: '編輯佇列項目',
-    printToPrinters: '列印到 {{count}} 臺印表機',
-    queueToPrinters: '佇列到 {{count}} 臺印表機',
-    queueSelectedPlates: '將 {{count}} 個熱床加入佇列',
     selectAllPlates: '選擇全部 {{count}} 個熱床',
     deselectAll: '取消全選',
     printQueued: '已加入列印佇列',
+    printQueuedWillStartWhenIdle: '印表機閒置時將開始',
     itemsQueued: '{{count}} 個任務已加入佇列',
     sending: '傳送中...',
     sendingProgress: '傳送中 {{current}}/{{total}}...',
@@ -1185,8 +1174,8 @@ export default {
     },
     // Actions
     actions: {
-      stopPrint: '停止列印',
       startPrint: '開始列印',
+      stopPrint: '停止列印',
       requeue: '重新佇列',
     },
     // Bulk edit
@@ -1304,35 +1293,6 @@ export default {
     },
   },
 
-  backgroundDispatch: {
-    unknownFile: '未知檔案',
-    unknownPrinter: '未知印表機',
-    startingPrints: '正在開始列印',
-    progressSummary: '{{complete}}/{{total}} 完成 • 已分發:{{dispatched}} • 處理中:{{processing}}',
-    expandDetails: '展開分發詳情',
-    collapseDetails: '收起分發詳情',
-    dismissToast: '關閉分發通知',
-    cancelDispatchJob: '取消分發任務',
-    cancel: '取消',
-    cancelling: '取消中…',
-    awaitingPrinter: '等待印表機…',
-    status: {
-      dispatched: '已分發',
-      processing: '處理中',
-      completed: '已完成',
-      failed: '失敗',
-      cancelled: '已取消',
-    },
-    toast: {
-      cancellingUpload: '取消上傳中...',
-      cancelled: '分發已取消',
-      cancelFailed: '取消分發失敗',
-      completeWithFailures: '後台分發完成:{{completed}} 成功,{{failed}} 失敗',
-      completeSuccess: '後台分發完成:{{completed}} 成功',
-      printStartedRemaining: '{{completed}} 個列印已開始,{{remaining}} 個正在傳送...',
-    },
-  },
-
   // Statistics page
   stats: {
     title: '統計',
@@ -3342,8 +3302,6 @@ export default {
     changeLink: '更改連結...',
     linkTo: '連結到...',
     linkToProjectOrArchive: '連結到專案或歸檔',
-    addToQueue: '新增到佇列',
-    schedulePrint: '排程',
     generateThumbnail: '產生縮圖',
     generateThumbnails: '產生縮圖',
     generateThumbnailsForMissing: '為缺少縮圖的 STL 檔案產生縮圖',
@@ -3646,8 +3604,6 @@ export default {
       fileCount: '{{count}} 個檔案',
       empty: '未連結資料夾。前往檔案管理器將資料夾連結到此項目。',
       noFiles: '此資料夾中沒有檔案。',
-      print: '立即列印',
-      addToQueue: '加入佇列',
     },
     bom: {
       title: '材料清單',
@@ -4286,7 +4242,6 @@ export default {
 
   // Print modal
   printModal: {
-    title: '開始列印',
     selectPrinter: '選擇印表機',
     selectPlate: '選擇板',
     filamentMapping: '耗材對應',
@@ -4298,15 +4253,25 @@ export default {
     vibrationCalibration: '振動校準',
     layerInspection: '首層檢查',
     timelapse: '縮時攝影',
-    startPrint: '開始列印',
-    addToQueue: '新增到佇列',
     cancel: '取消',
     noPrintersAvailable: '無可用印表機',
     printerBusy: '印表機忙碌',
     printerOffline: '印表機離線',
     sameTypeDifferentColor: '相同類型,不同顏色',
     filamentTypeNotLoaded: '耗材類型未裝載',
+    whenToPrint: '何時列印',
+    asap: '盡快',
+    queue: '佇列',
+    schedule: '排程',
+    dateTime: '日期和時間',
+    invalidDateTime: '請輸入有效的日期和時間',
     openCalendar: '開啟日曆',
+    requireManualStart: '要求手動開始',
+    requirePreviousSuccess: '僅在上一次列印成功後開始',
+    autoOffAfter: '完成後關閉印表機',
+    helpAsap: '列印將新增到佇列頂端,並在符合條件的印表機閒置後立即開始。',
+    helpSchedule: '如果印表機閒置,列印將在排程時間開始。如果印表機忙碌,則會等待其可用。',
+    helpQueue: '列印將新增到佇列末端。',
     leftNozzle: '左',
     rightNozzle: '右',
     leftNozzleTooltip: '左噴嘴',

+ 25 - 59
frontend/src/pages/ArchivesPage.tsx

@@ -204,7 +204,6 @@ function ArchiveCard({
   const [showQRCode, setShowQRCode] = useState(false);
   const [showPhotos, setShowPhotos] = useState(false);
   const [showProjectPage, setShowProjectPage] = useState(false);
-  const [showSchedule, setShowSchedule] = useState(false);
   const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
   const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
   const [showDeleteTimelapseConfirm, setShowDeleteTimelapseConfirm] = useState(false);
@@ -404,18 +403,17 @@ function ArchiveCard({
     // For source files: show Slice as the primary action
     ...(isGcodeFile ? [
       {
-        label: t('archives.menu.print'),
+        label: t('common.print'),
         icon: <Printer className="w-4 h-4" />,
         onClick: () => setShowReprint(true),
-        disabled: !archive.file_path || !canModify('archives', 'reprint', archive.created_by_id),
-        title: !archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.permission.noReprint') : undefined,
-      },
-      {
-        label: t('archives.menu.schedule'),
-        icon: <Calendar className="w-4 h-4" />,
-        onClick: () => setShowSchedule(true),
-        disabled: !archive.file_path || !hasPermission('queue:create'),
-        title: !archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : undefined,
+        disabled: !archive.file_path || !hasPermission('queue:create') || !canModify('archives', 'reprint', archive.created_by_id),
+        title: !archive.file_path
+          ? t('archives.card.noFileForReprint')
+          : !hasPermission('queue:create')
+            ? t('archives.permission.noAddToQueue')
+            : !canModify('archives', 'reprint', archive.created_by_id)
+              ? t('archives.permission.noReprint')
+              : undefined,
       },
       {
         label: t('archives.menu.openInBambuStudio'),
@@ -1144,22 +1142,11 @@ function ArchiveCard({
                 size="sm"
                 className="flex-1 min-w-0 overflow-hidden"
                 onClick={() => setShowReprint(true)}
-                disabled={!archive.file_path || !canModify('archives', 'reprint', archive.created_by_id)}
-                title={!archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.card.noPermissionReprint') : undefined}
+                disabled={!archive.file_path || !hasPermission('queue:create') || !canModify('archives', 'reprint', archive.created_by_id)}
+                title={!archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.card.noPermissionReprint') : undefined}
               >
                 <Printer className="w-3 h-3 flex-shrink-0" />
-                <span className="hidden xl:inline truncate">{t('archives.card.reprint')}</span>
-              </Button>
-              <Button
-                variant="secondary"
-                size="sm"
-                className="flex-1 min-w-0 overflow-hidden"
-                onClick={() => setShowSchedule(true)}
-                disabled={!archive.file_path || !hasPermission('queue:create')}
-                title={!archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : t('archives.card.schedulePrint')}
-              >
-                <Calendar className="w-3 h-3 flex-shrink-0" />
-                <span className="hidden xl:inline truncate">{t('archives.card.schedule')}</span>
+                <span className="hidden xl:inline truncate">{t('common.print')}</span>
               </Button>
               <Button
                 variant="secondary"
@@ -1275,7 +1262,7 @@ function ArchiveCard({
       {/* Reprint Modal */}
       {showReprint && (
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowReprint(false)}
@@ -1504,15 +1491,6 @@ function ArchiveCard({
         />
       )}
 
-      {showSchedule && (
-        <PrintModal
-          mode="add-to-queue"
-          archiveId={archive.id}
-          archiveName={archive.print_name || archive.filename}
-          onClose={() => setShowSchedule(false)}
-        />
-      )}
-
       {/* Hidden file input for source 3MF upload */}
       <input
         ref={source3mfInputRef}
@@ -1604,7 +1582,6 @@ function ArchiveListRow({
   const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
-  const [showSchedule, setShowSchedule] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
@@ -1785,18 +1762,17 @@ function ArchiveListRow({
   const contextMenuItems: ContextMenuItem[] = [
     ...(isGcodeFile ? [
       {
-        label: t('archives.menu.print'),
+        label: t('common.print'),
         icon: <Printer className="w-4 h-4" />,
         onClick: () => setShowReprint(true),
-        disabled: !archive.file_path || !canModify('archives', 'reprint', archive.created_by_id),
-        title: !archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.permission.noReprint') : undefined,
-      },
-      {
-        label: t('archives.menu.schedule'),
-        icon: <Calendar className="w-4 h-4" />,
-        onClick: () => setShowSchedule(true),
-        disabled: !archive.file_path || !hasPermission('queue:create'),
-        title: !archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : undefined,
+        disabled: !archive.file_path || !hasPermission('queue:create') || !canModify('archives', 'reprint', archive.created_by_id),
+        title: !archive.file_path
+          ? t('archives.card.noFileForReprint')
+          : !hasPermission('queue:create')
+            ? t('archives.permission.noAddToQueue')
+            : !canModify('archives', 'reprint', archive.created_by_id)
+              ? t('archives.permission.noReprint')
+              : undefined,
       },
       {
         label: t('archives.menu.openInBambuStudio'),
@@ -2185,8 +2161,8 @@ function ArchiveListRow({
               variant="ghost"
               size="sm"
               onClick={() => setShowReprint(true)}
-              disabled={!canModify('archives', 'reprint', archive.created_by_id)}
-              title={!canModify('archives', 'reprint', archive.created_by_id) ? t('archives.card.noPermissionReprint') : t('archives.card.reprint')}
+              disabled={!archive.file_path || !hasPermission('queue:create') || !canModify('archives', 'reprint', archive.created_by_id)}
+              title={!archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.card.noPermissionReprint') : t('common.print')}
               className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
             >
               <Play className="w-4 h-4" />
@@ -2289,7 +2265,7 @@ function ArchiveListRow({
       {/* Reprint Modal */}
       {showReprint && (
         <PrintModal
-          mode="reprint"
+          mode="create"
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowReprint(false)}
@@ -2505,16 +2481,6 @@ function ArchiveListRow({
         />
       )}
 
-      {/* Schedule Modal */}
-      {showSchedule && (
-        <PrintModal
-          mode="add-to-queue"
-          archiveId={archive.id}
-          archiveName={archive.print_name || archive.filename}
-          onClose={() => setShowSchedule(false)}
-        />
-      )}
-
       {/* Hidden file input for source 3MF upload */}
       <input
         ref={source3mfInputRef}

+ 13 - 94
frontend/src/pages/FileManagerPage.tsx

@@ -34,7 +34,6 @@ import {
   Cog,
   Printer,
   Pencil,
-  Play,
   Image,
   User,
   Box,
@@ -728,7 +727,6 @@ interface FileCardProps {
   onSelect: (id: number) => void;
   onDelete: (id: number) => void;
   onDownload: (id: number) => void;
-  onAddToQueue?: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onSlice?: (file: LibraryFileListItem) => void;
   useSlicerApi?: boolean;
@@ -743,7 +741,7 @@ interface FileCardProps {
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onSlice, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
@@ -843,27 +841,14 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
               {onPrint && isSlicedFilename(file.filename) && (
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('printers:control') ? 'text-bambu-green hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    hasPermission('queue:create') ? 'text-bambu-green hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                   }`}
-                  onClick={() => { if (hasPermission('printers:control')) { onPrint(file); setShowActions(false); } }}
-                  disabled={!hasPermission('printers:control')}
-                  title={!hasPermission('printers:control') ? t('fileManager.noPermissionPrint') : undefined}
-                >
-                  <Printer className="w-3.5 h-3.5" />
-                  {t('common.print')}
-                </button>
-              )}
-              {onAddToQueue && isSlicedFilename(file.filename) && (
-                <button
-                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('queue:create') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
-                  }`}
-                  onClick={() => { if (hasPermission('queue:create')) { onAddToQueue(file.id); setShowActions(false); } }}
+                  onClick={() => { if (hasPermission('queue:create')) { onPrint(file); setShowActions(false); } }}
                   disabled={!hasPermission('queue:create')}
                   title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
                 >
-                  <Clock className="w-3.5 h-3.5" />
-                  {t('fileManager.schedulePrint')}
+                  <Printer className="w-3.5 h-3.5" />
+                  {t('common.print')}
                 </button>
               )}
               {onSlice && useSlicerApi && isSliceableFilename(file.filename) && (
@@ -992,8 +977,6 @@ export function FileManagerPage() {
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
-  const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
-  const [scheduleFile, setScheduleFile] = useState<LibraryFileListItem | null>(null);
   const [sliceFile, setSliceFile] = useState<LibraryFileListItem | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
@@ -2160,27 +2143,12 @@ export function FileManagerPage() {
                       <Button
                         variant="primary"
                         size="sm"
-                        onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
-                        disabled={!hasPermission('printers:control')}
-                        title={!hasPermission('printers:control') ? t('fileManager.noPermissionPrint') : undefined}
-                      >
-                        <Play className="w-4 h-4 sm:mr-1" />
-                        <span className="hidden sm:inline">{t('common.print')}</span>
-                      </Button>
-                    )}
-                    {selectedSlicedFiles.length === 1 && (
-                      <Button
-                        variant="secondary"
-                        size="sm"
-                        // Note: Schedule dialog (PrintModal) is designed for single file at a time
-                        // but supports scheduling to multiple printers. This provides more control
-                        // over scheduling options compared to the previous bulk queue mutation.
-                        onClick={() => setScheduleFile(selectedSlicedFiles[0])}
+                        onClick={() => setPrintFile(selectedSlicedFiles[0])}
                         disabled={!hasPermission('queue:create')}
                         title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
                       >
-                        <Clock className="w-4 h-4 sm:mr-1" />
-                        <span className="hidden sm:inline">{t('fileManager.schedulePrint')}</span>
+                        <Printer className="w-4 h-4 sm:mr-1" />
+                        <span className="hidden sm:inline">{t('common.print')}</span>
                       </Button>
                     )}
                     <Button
@@ -2295,10 +2263,6 @@ export function FileManagerPage() {
                     onSelect={handleFileSelect}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDownload={handleDownload}
-                    onAddToQueue={(id) => {
-                      const file = files?.find(f => f.id === id);
-                      if (file) setScheduleFile(file);
-                    }}
                     onPrint={setPrintFile}
                     onSlice={setSliceFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
@@ -2455,32 +2419,16 @@ export function FileManagerPage() {
                       {isSlicedFilename(file.filename) && (
                         <>
                           <button
-                            onClick={() => hasPermission('printers:control') && setPrintFile(file)}
-                            className={`p-1.5 rounded transition-colors ${
-                              hasPermission('printers:control')
-                                ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
-                                : 'text-bambu-gray/50 cursor-not-allowed'
-                            }`}
-                            title={hasPermission('printers:control') ? t('common.print') : t('fileManager.noPermissionPrint')}
-                            disabled={!hasPermission('printers:control')}
-                          >
-                            <Printer className="w-4 h-4" />
-                          </button>
-                          <button
-                            onClick={() => {
-                              if (hasPermission('queue:create')) {
-                                setScheduleFile(file);
-                              }
-                            }}
+                            onClick={() => hasPermission('queue:create') && setPrintFile(file)}
                             className={`p-1.5 rounded transition-colors ${
                               hasPermission('queue:create')
-                                ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
+                                ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                                 : 'text-bambu-gray/50 cursor-not-allowed'
                             }`}
-                            title={hasPermission('queue:create') ? t('fileManager.schedulePrint') : t('fileManager.noPermissionAddToQueue')}
+                            title={hasPermission('queue:create') ? t('common.print') : t('fileManager.noPermissionAddToQueue')}
                             disabled={!hasPermission('queue:create')}
                           >
-                            <Clock className="w-4 h-4" />
+                            <Printer className="w-4 h-4" />
                           </button>
                         </>
                       )}
@@ -2679,41 +2627,12 @@ export function FileManagerPage() {
 
       {printFile && (
         <PrintModal
-          mode="reprint"
+          mode="create"
           libraryFileId={printFile.id}
           archiveName={printFile.print_name || printFile.filename}
           onClose={() => setPrintFile(null)}
           onSuccess={() => {
             setPrintFile(null);
-            queryClient.invalidateQueries({ queryKey: ['library-files'] });
-            queryClient.invalidateQueries({ queryKey: ['archives'] });
-          }}
-        />
-      )}
-
-      {printMultiFile && (
-        <PrintModal
-          mode="reprint"
-          libraryFileId={printMultiFile.id}
-          archiveName={printMultiFile.print_name || printMultiFile.filename}
-          onClose={() => setPrintMultiFile(null)}
-          onSuccess={() => {
-            setPrintMultiFile(null);
-            setSelectedFiles([]);
-            queryClient.invalidateQueries({ queryKey: ['library-files'] });
-            queryClient.invalidateQueries({ queryKey: ['archives'] });
-          }}
-        />
-      )}
-
-      {scheduleFile && (
-        <PrintModal
-          mode="add-to-queue"
-          libraryFileId={scheduleFile.id}
-          archiveName={scheduleFile.print_name || scheduleFile.filename}
-          onClose={() => setScheduleFile(null)}
-          onSuccess={() => {
-            setScheduleFile(null);
             setSelectedFiles([]);
             queryClient.invalidateQueries({ queryKey: ['library-files'] });
             queryClient.invalidateQueries({ queryKey: ['queue'] });

+ 9 - 3
frontend/src/pages/PrintersPage.tsx

@@ -5629,8 +5629,14 @@ function PrinterCard({
                   <Button
                     size="sm"
                     onClick={() => setShowUploadForPrint(true)}
-                    disabled={!hasPermission('printers:control')}
-                    title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('common.print')}
+                    disabled={!hasPermission('library:upload') || !hasPermission('queue:create')}
+                    title={
+                      !hasPermission('library:upload')
+                        ? t('fileManager.noPermissionUpload')
+                        : !hasPermission('queue:create')
+                          ? t('fileManager.noPermissionAddToQueue')
+                          : t('common.print')
+                    }
                     className={`${footerActionButtonClass} !bg-bambu-green hover:!bg-bambu-green/80 !text-white`}
                   >
                     <PrinterIcon className="w-4 h-4" />
@@ -5683,7 +5689,7 @@ function PrinterCard({
       {/* Print Modal (after upload) */}
       {printAfterUpload && (
         <PrintModal
-          mode="reprint"
+          mode="create"
           libraryFileId={printAfterUpload.id}
           archiveName={printAfterUpload.filename}
           initialSelectedPrinterIds={[printer.id]}

+ 4 - 28
frontend/src/pages/ProjectDetailPage.tsx

@@ -31,8 +31,6 @@ import {
   FolderOpen,
   Download,
   Pencil,
-  Play,
-  CalendarPlus,
   FileBox,
 } from 'lucide-react';
 import { api } from '../api/client';
@@ -212,7 +210,6 @@ export function ProjectDetailPage() {
   const [editingNotes, setEditingNotes] = useState(false);
   const [notesContent, setNotesContent] = useState('');
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
-  const [scheduleFile, setScheduleFile] = useState<LibraryFileListItem | null>(null);
 
   const projectId = parseInt(id || '0', 10);
 
@@ -961,17 +958,10 @@ export function ProjectDetailPage() {
                                 <div className="flex items-center gap-1 shrink-0">
                                   <button
                                     onClick={() => setPrintFile(file)}
-                                    title={t('projectDetail.files.print')}
+                                    title={t('common.print')}
                                     className="p-1.5 rounded hover:bg-bambu-green/20 text-bambu-green transition-colors"
                                   >
-                                    <Play className="w-4 h-4" />
-                                  </button>
-                                  <button
-                                    onClick={() => setScheduleFile(file)}
-                                    title={t('projectDetail.files.addToQueue')}
-                                    className="p-1.5 rounded hover:bg-blue-500/20 text-blue-400 transition-colors"
-                                  >
-                                    <CalendarPlus className="w-4 h-4" />
+                                    <Printer className="w-4 h-4" />
                                   </button>
                                 </div>
                               )}
@@ -1425,10 +1415,10 @@ export function ProjectDetailPage() {
         />
       )}
 
-      {/* Print directly from project — reprint mode */}
+      {/* Print from project */}
       {printFile && (
         <PrintModal
-          mode="reprint"
+          mode="create"
           libraryFileId={printFile.id}
           archiveName={printFile.print_name || printFile.filename}
           projectId={projectId}
@@ -1436,20 +1426,6 @@ export function ProjectDetailPage() {
           onSuccess={() => {
             setPrintFile(null);
             queryClient.invalidateQueries({ queryKey: ['archives'] });
-          }}
-        />
-      )}
-
-      {/* Add to queue from project */}
-      {scheduleFile && (
-        <PrintModal
-          mode="add-to-queue"
-          libraryFileId={scheduleFile.id}
-          archiveName={scheduleFile.print_name || scheduleFile.filename}
-          projectId={projectId}
-          onClose={() => setScheduleFile(null)}
-          onSuccess={() => {
-            setScheduleFile(null);
             queryClient.invalidateQueries({ queryKey: ['queue'] });
           }}
         />

+ 1 - 1
frontend/src/pages/QueuePage.tsx

@@ -2396,7 +2396,7 @@ export function QueuePage() {
       {/* Re-queue Modal */}
       {requeueItem && (
         <PrintModal
-          mode="add-to-queue"
+          mode="create"
           archiveId={requeueItem.archive_id ?? undefined}
           libraryFileId={requeueItem.library_file_id ?? undefined}
           archiveName={requeueItem.archive_name || requeueItem.library_file_name || `File #${requeueItem.archive_id || requeueItem.library_file_id}`}

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-BKwIZ5yr.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CkDEALWj.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-DIWYFok8.css


+ 2 - 2
static/index.html

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

Некоторые файлы не были показаны из-за большого количества измененных файлов