9 Комити 770c6bfdf9 ... 833a096da6

Аутор SHA1 Порука Датум
  maziggy 833a096da6 test/chore: Bandit findings on PR #3350 marked with reasons (ftplib in tests, migration SQL built from module constants), a misplaced nosec moved to its SQL line; layer timelapse tests use tmp_path instead of a shared /tmp/test_bambuddy пре 1 дан
  maziggy 76ed16509f chore(deps): pillow 13.0.0 and certifi 2026.10.19, multidict floor 6.9.1 (pip-audit findings); locks regenerated for these packages only пре 1 дан
  maziggy 3043dae4fc chore(deps): vite 8.1.6 and source-map-js 1.2.2 (npm audit: launch-editor and source-map advisories in dev tooling) пре 1 дан
  maziggy e290a57c84 test: vitest shows console output only for failing tests (silent: 'passed-only' in vitest.config.ts, so Docker runs are quiet too); test_frontend.sh back to npm run test:run пре 1 дан
  maziggy 9083cd2a35 test: Docker test runs use 30 workers (-n auto counted physical cores only); tests stop the background tasks they started and close the app engine's connections after every test on SQLite too ("Event loop is closed" thread warning) пре 1 дан
  maziggy 198610476f test: Docker test runs use all logical CPUs (-n logical; auto counted physical cores only); tests stop the background tasks they started and close the app engine's connections after every test on SQLite too ("Event loop is closed" thread warning) пре 1 дан
  maziggy 92c307786a test: test_frontend.sh hides console output of passing tests (vitest --silent=passed-only) пре 1 дан
  maziggy 2443cea174 feat: USB cameras are picked from a list of detected devices (refresh, not-found marking, manual fallback, Docker hint; device listing off the event loop, /dev/video0-99 only); fix: timelapse editor saves as a background job with progress and cancel (ffmpeg stopped on cancel, no overwrite of a timelapse removed or replaced meanwhile, ffprobe/thumbnail timeouts), and the music volume is applied пре 1 дан
  maziggy 460c11ca30 chore: remove dead code (audit step 4): unused backend helpers and schema classes, 51 unused API client wrappers, test-only components (BackupModal, RestoreModal, VirtualPrinterSettings, TagDetectedModal, WeightDisplay, useLongPress) and their translations; removed POST /printers/test, the two printer debug routes and the SpoolBuddy update-status channel; legacy virtual printer settings fields removed from the settings API; first-run setup redirect tested пре 1 дан
100 измењених фајлова са 1348 додато и 3466 уклоњено
  1. 0 88
      .basedpyright/baseline.json
  2. 8 0
      CHANGELOG.md
  3. 4 4
      Dockerfile.test
  4. 129 63
      backend/app/api/routes/archives.py
  5. 1 1
      backend/app/api/routes/orca_cloud.py
  6. 3 100
      backend/app/api/routes/printers.py
  7. 7 37
      backend/app/api/routes/settings.py
  8. 0 36
      backend/app/api/routes/spoolbuddy.py
  9. 0 17
      backend/app/api/routes/support.py
  10. 0 11
      backend/app/core/auth.py
  11. 9 9
      backend/app/core/database.py
  12. 0 4
      backend/app/core/printer_scope.py
  13. 4 4
      backend/app/main.py
  14. 0 87
      backend/app/models/auth_ephemeral.py
  15. 0 12
      backend/app/schemas/archive.py
  16. 0 6
      backend/app/schemas/auth.py
  17. 0 18
      backend/app/schemas/cloud.py
  18. 0 14
      backend/app/schemas/library.py
  19. 0 5
      backend/app/schemas/library_trash.py
  20. 0 8
      backend/app/schemas/maintenance.py
  21. 0 72
      backend/app/schemas/notification.py
  22. 0 7
      backend/app/schemas/notification_template.py
  23. 1 1
      backend/app/schemas/orca_cloud.py
  24. 0 44
      backend/app/schemas/printer.py
  25. 0 19
      backend/app/schemas/project.py
  26. 0 9
      backend/app/schemas/settings.py
  27. 0 7
      backend/app/schemas/spoolbuddy.py
  28. 18 6
      backend/app/schemas/timelapse.py
  29. 0 6
      backend/app/services/bambu_cloud.py
  30. 0 135
      backend/app/services/bambu_ftp.py
  31. 3 18
      backend/app/services/bambu_mqtt.py
  32. 17 10
      backend/app/services/external_camera.py
  33. 1 13
      backend/app/services/github_backup.py
  34. 0 5
      backend/app/services/github_restore.py
  35. 0 5
      backend/app/services/layer_timelapse.py
  36. 2 3
      backend/app/services/location_service.py
  37. 0 4
      backend/app/services/mqtt_smart_plug.py
  38. 0 3
      backend/app/services/notify_client.py
  39. 4 49
      backend/app/services/printer_manager.py
  40. 0 1
      backend/app/services/secret_fields.py
  41. 0 11
      backend/app/services/slicer_api.py
  42. 2 12
      backend/app/services/smart_plug_manager.py
  43. 0 72
      backend/app/services/spoolman.py
  44. 0 3
      backend/app/services/telegram_reactions.py
  45. 89 0
      backend/app/services/timelapse_jobs.py
  46. 82 3
      backend/app/services/timelapse_processor.py
  47. 0 82
      backend/app/services/virtual_printer/certificate.py
  48. 11 25
      backend/app/services/virtual_printer/manager.py
  49. 0 5
      backend/app/utils/filament_types.py
  50. 0 21
      backend/app/utils/threemf_tools.py
  51. 34 13
      backend/tests/conftest.py
  52. 1 1
      backend/tests/integration/test_pipeline_runs_api.py
  53. 6 6
      backend/tests/integration/test_print_queue_api.py
  54. 0 1
      backend/tests/integration/test_settings_and_plug_secrets.py
  55. 0 2
      backend/tests/integration/test_settings_api_key_scrubbing.py
  56. 0 88
      backend/tests/integration/test_spoolbuddy.py
  57. 0 2
      backend/tests/integration/test_spoolman_api.py
  58. 0 2
      backend/tests/integration/test_system_support_vp_scope.py
  59. 12 7
      backend/tests/integration/test_telegram_reactions.py
  60. 239 0
      backend/tests/integration/test_timelapse_editor_job.py
  61. 23 5
      backend/tests/integration/test_virtual_printer_api.py
  62. 5 127
      backend/tests/unit/services/test_bambu_ftp.py
  63. 3 3
      backend/tests/unit/services/test_ftp_download_deadlines.py
  64. 14 47
      backend/tests/unit/services/test_layer_timelapse.py
  65. 4 5
      backend/tests/unit/services/test_notify_widget_client.py
  66. 0 45
      backend/tests/unit/services/test_printer_manager.py
  67. 0 23
      backend/tests/unit/services/test_slicer_api.py
  68. 0 15
      backend/tests/unit/services/test_smart_plug_manager.py
  69. 0 47
      backend/tests/unit/services/test_spoolman_service.py
  70. 197 0
      backend/tests/unit/services/test_timelapse_jobs.py
  71. 56 0
      backend/tests/unit/services/test_usb_camera_listing.py
  72. 12 55
      backend/tests/unit/services/test_virtual_printer.py
  73. 7 5
      backend/tests/unit/test_filament_type_equivalence.py
  74. 1 2
      backend/tests/unit/test_github_restore.py
  75. 0 38
      backend/tests/unit/test_permissions_stats_filter.py
  76. 0 1
      backend/tests/unit/test_printer_scope.py
  77. 1 39
      backend/tests/unit/test_spoolbuddy_schema_validation.py
  78. 0 186
      backend/tests/unit/test_spoolman_clear_location.py
  79. 0 40
      backend/tests/unit/test_support_helpers.py
  80. 13 13
      backend/tests/unit/test_threemf_tools.py
  81. 0 76
      backend/tests/unit/test_vp_certificate_rotation.py
  82. 1 1
      backend/tests/unit/test_vp_tls_ciphers.py
  83. 2 2
      docker-compose.test.yml
  84. 132 102
      frontend/package-lock.json
  85. 1 1
      frontend/package.json
  86. 0 3
      frontend/src/__tests__/components/AdminDisableTwoFactor.test.tsx
  87. 0 88
      frontend/src/__tests__/components/BackupModal.test.tsx
  88. 0 3
      frontend/src/__tests__/components/BulkPrinterToolbarWiring.test.tsx
  89. 0 3
      frontend/src/__tests__/components/PrintModalBilling.test.tsx
  90. 0 89
      frontend/src/__tests__/components/RestoreModal.test.tsx
  91. 0 3
      frontend/src/__tests__/components/SkipObjectsModalWiring.test.tsx
  92. 0 2
      frontend/src/__tests__/components/SpoolmanSettings.test.tsx
  93. 0 110
      frontend/src/__tests__/components/TagDetectedModal.test.tsx
  94. 155 0
      frontend/src/__tests__/components/TimelapseEditorModalJob.test.tsx
  95. 0 680
      frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
  96. 0 80
      frontend/src/__tests__/components/WeightDisplay.test.tsx
  97. 0 1
      frontend/src/__tests__/components/spool-form/PAProfileSectionNozzle.test.tsx
  98. 7 23
      frontend/src/__tests__/components/spoolbuddy/SpoolBuddyColorName3090.test.tsx
  99. 27 45
      frontend/src/__tests__/contexts/AuthContext.test.tsx
  100. 0 161
      frontend/src/__tests__/hooks/useLongPress.test.ts

+ 0 - 88
.basedpyright/baseline.json

@@ -2159,30 +2159,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportOptionalMemberAccess",
-                "range": {
-                    "startColumn": 45,
-                    "endColumn": 55,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportOptionalMemberAccess",
-                "range": {
-                    "startColumn": 31,
-                    "endColumn": 41,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportOptionalMemberAccess",
-                "range": {
-                    "startColumn": 35,
-                    "endColumn": 45,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {
@@ -4398,14 +4374,6 @@
                     "endColumn": 88,
                     "lineCount": 1
                 }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 58,
-                    "endColumn": 66,
-                    "lineCount": 1
-                }
             }
         ],
         "./backend/app/services/spoolman_tracking.py": [
@@ -4519,62 +4487,6 @@
             }
         ],
         "./backend/app/services/virtual_printer/certificate.py": [
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 37,
-                    "endColumn": 43,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 37,
-                    "endColumn": 43,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 37,
-                    "endColumn": 43,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 37,
-                    "endColumn": 43,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportCallIssue",
-                "range": {
-                    "startColumn": 20,
-                    "endColumn": 38,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 20,
-                    "endColumn": 49,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportCallIssue",
-                "range": {
-                    "startColumn": 20,
-                    "endColumn": 49,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportReturnType",
                 "range": {

+ 8 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **USB cameras are picked from a list** — With an external camera set to **USB Camera (V4L2)**, Settings > Camera now lists the video devices the Bambuddy server can see, by name and path (`HD Pro Webcam C920 (/dev/video0)`), instead of asking for the device path. A refresh button looks again after a camera is plugged in, and a saved camera that is unplugged stays selected, marked **not found**. When no device is visible, the list says how to pass a camera to the Docker container. **Enter path manually** still takes a typed path. Looking for devices no longer holds up the server while it runs.
 - **The MQTT relay publishes printer offline/online and low filament** — Three new topics for Home Assistant, Node-RED and other subscribers. `printers/{serial}/offline` is sent once a printer has been disconnected for a minute, and `printers/{serial}/online` when it comes back after that, so a short blip sends neither. `printers/{serial}/filament/low` is sent once when the spool in an AMS slot drops below its low-stock threshold, at the same moment as the Low Filament notification, and works even when no notification provider has that event switched on. Wiki updated with the payloads.
 - **Notify! notification provider with iOS Live Activities and Lock Screen widgets (#2919, requested and contributed by @simplytoast1 in #3265)** — Notify! used to need the separate notify-bambuddy companion container. Bambuddy now talks to it directly: add a provider of type Notify! with the device ID and token.
   - **Alerts:** the usual events, printer filter, quiet hours and daily digest apply as for every provider, with the camera photo attached. Each printer gets its own notification thread. **Time Sensitive problem alerts** sends failures, stops, printer errors and failure detection as time-sensitive notifications.
@@ -135,6 +136,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
 
 ### Changed
+- **Unused API routes and settings removed** — For anyone calling the API from scripts:
+  - **Removed routes:** `POST /printers/test` (it took the access code in the URL; adding a printer tests the connection itself), the two debug routes `POST /printers/{id}/debug/simulate-print-complete` and `GET /printers/{id}/runtime-debug`, and `POST /spoolbuddy/devices/{id}/update-status`, which SpoolBuddy stopped calling when updates moved to SSH.
+  - **Old virtual printer settings:** `GET /settings/virtual-printer` now returns only `tailscale_disabled`, `archive_name_source` and `status`, and `GET /settings` no longer lists `virtual_printer_enabled`, `virtual_printer_access_code` and `virtual_printer_mode` (`PUT /settings` ignores them). These settings stopped doing anything when each virtual printer got its own settings; use `/virtual-printers` instead.
 - **A grouping that ended in failures is marked failed** — A grouping whose jobs all failed, or failed and were cancelled, with nothing printed, stayed **active** for good, even through the start-up clean-up, and piled up next to real work. It is now marked **failed**, shown in red with a **Failed** filter on the Batches tab, and opens again if a job is added to it. Orders are unchanged: a failed run still leaves an order owing it.
 - **Deleting a custom maintenance type says it applies to every printer** — Maintenance types are shared, so deleting a custom one removes it, with its maintenance history, from every printer, including those of other teams when printer access is limited. The confirmation now says so.
 - **The web interface downloads about 70% less on each new version (#3175, requested by @mason9468)** — The interface's script files were sent uncompressed and with no caching instructions, so a phone fetched the whole 11 MB again and the browser asked for it on every load.
@@ -164,6 +168,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Saving in the timelapse editor could fail on long videos, and ignored the music volume** — Save re-encoded the video while the browser waited, which for a long timelapse took minutes. A browser or reverse proxy that gave up showed an error while FFmpeg went on running on the server.
+  - **Runs in the background:** Save now starts the work on the server and returns at once. The button shows how far it has got, **Cancel** stops FFmpeg and keeps the original, and an editor closed and opened again shows the progress. While one edit of a timelapse runs, Save is unavailable for it. If the timelapse is removed or replaced while the edit runs, the edit is not saved over it.
+  - **Music volume:** the volume slider only changed the preview; the saved video always had the music at full volume. It is now mixed in at the volume set, and muted means silent.
+  - On a broken file, reading the video's details now gives up after a minute and each timeline frame after 30 seconds, instead of waiting forever.
 - **Firmware could be uploaded to a printer in the middle of a print** — The firmware update dialog offered **Upload Firmware** whatever the printer was doing, and the upload wrote to the SD card the print runs from. It is now only offered while the printer is idle (a finished or failed print counts), and the server refuses it otherwise. If a print starts while the firmware is still downloading, nothing is written to the card, and the queue holds its next print for a printer until its firmware upload is over.
 - **Reset Slot cleared an AMS slot on a single click, even mid-print** — It now asks first, and is unavailable while a print is running, paused or starting, since the print may be feeding from that slot.
 - **Some destructive buttons acted without asking** — **Clear all** on the shopping list, **Clear** in the Git backup history and removing a linked SSO account now ask for confirmation first. An account created through SSO, which has no password, can no longer remove its last SSO link, which was its only way to sign in.

+ 4 - 4
Dockerfile.test

@@ -46,10 +46,10 @@ HEALTHCHECK NONE
 # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat noticeable
 # stdout I/O time and clutter test_docker.sh output. --tb=short still
 # gives full failure tracebacks when something breaks.
-# -n auto adapts to the host's vCPU count instead of hard-coding 30 —
-# on a 2-vCPU CI / VM runner, -n 30 spawns 30 Python processes fighting
-# for 2 cores, which is mostly IPC + import-thrash overhead.
-CMD ["pytest", "backend/tests/", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"]
+# -n 30: the test host's worker count. Not -n auto (with psutil installed
+# that counts physical cores only, 16) nor -n logical (the Docker host's 32
+# CPUs, more than this machine is meant to use).
+CMD ["pytest", "backend/tests/", "--tb=short", "-p", "no:cacheprovider", "-n", "30"]
 
 # -------------------------------------------
 # Frontend test stage

+ 129 - 63
backend/app/api/routes/archives.py

@@ -244,15 +244,6 @@ def _validate_user_filter_permission(current_user: User | ApiKeyActor | None, cr
         raise HTTPException(status_code=403, detail="Permission stats:filter_by_user required")
 
 
-def _apply_user_filter(conditions: list, created_by_id: int | None):
-    """Append created_by_id filter to conditions list if specified."""
-    if created_by_id is not None:
-        if created_by_id == -1:
-            conditions.append(PrintArchive.created_by_id.is_(None))
-        else:
-            conditions.append(PrintArchive.created_by_id == created_by_id)
-
-
 def _apply_run_user_filter(conditions: list, created_by_id: int | None):
     """Append created_by_id filter scoped to PrintLogEntry rows."""
     from backend.app.models.print_log import PrintLogEntry
@@ -3375,12 +3366,13 @@ async def get_timelapse_thumbnails(
         raise HTTPException(500, f"Failed to generate thumbnails: {str(e)}")
 
 
-@router.post("/{archive_id}/timelapse/process")
+@router.post("/{archive_id}/timelapse/process", status_code=202)
 async def process_timelapse(
     archive_id: int,
     trim_start: float = Form(0),
     trim_end: float = Form(None),
     speed: float = Form(1.0),
+    audio_volume: float = Form(1.0),
     save_mode: str = Form("new"),
     output_filename: str = Form(None),
     audio: UploadFile = File(None),
@@ -3388,17 +3380,26 @@ async def process_timelapse(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Process timelapse with trim, speed, and optional audio overlay."""
+    """Start re-encoding the timelapse with trim, speed and optional audio.
+
+    Runs as a background job: poll ``GET .../timelapse/job`` for progress and
+    the result, ``DELETE .../timelapse/job`` cancels it.
+    """
     import shutil
     import tempfile
+    import uuid
 
-    from backend.app.schemas.timelapse import ProcessResponse
+    from backend.app.schemas.timelapse import TimelapseJobResponse
+    from backend.app.services import timelapse_jobs
     from backend.app.services.timelapse_processor import TimelapseProcessor
 
     # Validate speed
     if not 0.25 <= speed <= 4.0:
         raise HTTPException(400, "Speed must be between 0.25 and 4.0")
 
+    if not 0.0 <= audio_volume <= 1.0:
+        raise HTTPException(400, "audio_volume must be between 0 and 1")
+
     if save_mode not in ("replace", "new"):
         raise HTTPException(400, "save_mode must be 'replace' or 'new'")
 
@@ -3411,7 +3412,37 @@ async def process_timelapse(
     if not timelapse_path.exists():
         raise HTTPException(404, "Timelapse file not found")
 
+    current = timelapse_jobs.get_job(archive_id)
+    if current is not None and current.status == timelapse_jobs.RUNNING:
+        raise HTTPException(409, "This timelapse is already being processed")
+
+    try:
+        processor = TimelapseProcessor(timelapse_path)
+    except RuntimeError as e:
+        raise HTTPException(500, f"Processing failed: {e}") from None
+
     archive_dir = timelapse_path.parent
+    stored_timelapse_path = archive.timelapse_path
+    # What is being edited, so a replace does not overwrite a timelapse removed
+    # or swapped for another one while the job ran.
+    source = timelapse_path.stat()
+    source_signature = (source.st_size, source.st_mtime_ns, source.st_ino)
+
+    # Determine output path. Unique temp names: two archives' jobs run at once.
+    if save_mode == "replace":
+        # Process to temp file first, then replace
+        output_path = Path(tempfile.gettempdir()) / f"processed_{archive_id}_{uuid.uuid4().hex}.mp4"
+    else:
+        # Save as new file alongside original
+        filename = output_filename or f"{archive.print_name or 'timelapse'}_edited.mp4"
+        # Sanitize filename - remove path separators and traversal sequences
+        filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
+        # Prevent path traversal
+        if ".." in filename or not filename or filename.startswith("."):
+            filename = f"timelapse_{archive_id}_edited"
+        if not filename.endswith(".mp4"):
+            filename += ".mp4"
+        output_path = archive_dir / filename  # SEC-PATH-OK: filename alnum-filtered + .. rejected above
 
     # Handle audio file
     audio_temp_path = None
@@ -3425,65 +3456,100 @@ async def process_timelapse(
         suffix = Path(audio.filename).suffix.lower()
         if suffix not in (".mp3", ".wav", ".m4a", ".aac", ".ogg"):
             raise HTTPException(400, "Invalid audio file extension")
-        audio_temp_path = Path(tempfile.gettempdir()) / f"audio_{archive_id}{suffix}"
+        audio_temp_path = Path(tempfile.gettempdir()) / f"audio_{archive_id}_{uuid.uuid4().hex}{suffix}"
         audio_temp_path.write_bytes(audio_content)
 
+    async def run(job: timelapse_jobs.TimelapseJob) -> tuple[str, str]:
+        try:
+            success = await processor.process(
+                output_path=output_path,
+                trim_start=trim_start,
+                trim_end=trim_end,
+                speed=speed,
+                audio_path=audio_temp_path,
+                audio_volume=audio_volume,
+                on_progress=job.set_progress,
+            )
+            if not success:
+                raise RuntimeError("Video processing failed")
+
+            if save_mode == "replace":
+                await _ensure_timelapse_unchanged(archive_id, stored_timelapse_path, timelapse_path, source_signature)
+                shutil.move(str(output_path), str(timelapse_path))
+                return stored_timelapse_path, "Timelapse replaced successfully"
+            return str(output_path.relative_to(settings.base_dir)), f"Saved as {output_path.name}"
+        finally:
+            if save_mode == "replace":
+                output_path.unlink(missing_ok=True)
+            if audio_temp_path:
+                audio_temp_path.unlink(missing_ok=True)
+
     try:
-        processor = TimelapseProcessor(timelapse_path)
+        job = timelapse_jobs.start_job(archive_id, run)
+    except timelapse_jobs.JobAlreadyRunning:
+        # Started by another request while this one read its audio upload.
+        if audio_temp_path:
+            audio_temp_path.unlink(missing_ok=True)
+        raise HTTPException(409, "This timelapse is already being processed") from None
+    return TimelapseJobResponse.from_job(job)
+
+
+async def _ensure_timelapse_unchanged(
+    archive_id: int, stored_path: str, timelapse_path: Path, signature: tuple[int, int, int]
+) -> None:
+    async with database.async_session() as db:
+        current = (
+            await db.execute(select(PrintArchive.timelapse_path).where(PrintArchive.id == archive_id))
+        ).scalar_one_or_none()
+    try:
+        now = timelapse_path.stat()
+    except FileNotFoundError:
+        now = None
+    if current != stored_path or now is None or (now.st_size, now.st_mtime_ns, now.st_ino) != signature:
+        raise RuntimeError("The timelapse was removed or replaced while it was being edited; the edit was not saved")
 
-        # Determine output path
-        if save_mode == "replace":
-            # Process to temp file first, then replace
-            temp_output = Path(tempfile.gettempdir()) / f"processed_{archive_id}.mp4"
-            output_path = temp_output
-        else:
-            # Save as new file alongside original
-            filename = output_filename or f"{archive.print_name or 'timelapse'}_edited.mp4"
-            # Sanitize filename - remove path separators and traversal sequences
-            filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
-            # Prevent path traversal
-            if ".." in filename or not filename or filename.startswith("."):
-                filename = f"timelapse_{archive_id}_edited"
-            if not filename.endswith(".mp4"):
-                filename += ".mp4"
-            output_path = archive_dir / filename  # SEC-PATH-OK: filename alnum-filtered + .. rejected above
-
-        success = await processor.process(
-            output_path=output_path,
-            trim_start=trim_start,
-            trim_end=trim_end,
-            speed=speed,
-            audio_path=audio_temp_path,
-        )
 
-        if not success:
-            raise HTTPException(500, "Video processing failed")
+async def _timelapse_job_archive(archive_id: int, db: AsyncSession, printer_scope: PrinterScope) -> None:
+    archive = await ArchiveService(db).get_archive(archive_id)
+    if not archive or not printer_scope.allows(archive.printer_id):
+        raise HTTPException(404, "Timelapse not found")
 
-        # Handle save mode
-        if save_mode == "replace":
-            # Replace original file
-            shutil.move(str(output_path), str(timelapse_path))
-            final_path = archive.timelapse_path
-            message = "Timelapse replaced successfully"
-        else:
-            final_path = str(output_path.relative_to(settings.base_dir))
-            message = f"Saved as {output_path.name}"
 
-        return ProcessResponse(
-            status="completed",
-            output_path=final_path,
-            message=message,
-        )
+@router.get("/{archive_id}/timelapse/job")
+async def get_timelapse_job(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """The archive's running or last timelapse job: status, progress, result."""
+    from backend.app.schemas.timelapse import TimelapseJobResponse
+    from backend.app.services import timelapse_jobs
 
-    except HTTPException:
-        raise
-    except Exception as e:
-        logger.error("Timelapse processing failed: %s", e)
-        raise HTTPException(500, f"Processing failed: {str(e)}")
-    finally:
-        # Cleanup temp audio file
-        if audio_temp_path and audio_temp_path.exists():
-            audio_temp_path.unlink()
+    await _timelapse_job_archive(archive_id, db, printer_scope)
+    job = timelapse_jobs.get_job(archive_id)
+    if job is None:
+        raise HTTPException(404, "No timelapse job")
+    return TimelapseJobResponse.from_job(job)
+
+
+@router.delete("/{archive_id}/timelapse/job")
+async def cancel_timelapse_job(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Cancel the running timelapse job; stops ffmpeg and drops its output."""
+    from backend.app.schemas.timelapse import TimelapseJobResponse
+    from backend.app.services import timelapse_jobs
+
+    await _timelapse_job_archive(archive_id, db, printer_scope)
+    if not await timelapse_jobs.cancel_job(archive_id):
+        raise HTTPException(404, "No timelapse job is running")
+    job = timelapse_jobs.get_job(archive_id)
+    assert job is not None
+    return TimelapseJobResponse.from_job(job)
 
 
 # ============================================

+ 1 - 1
backend/app/api/routes/orca_cloud.py

@@ -679,7 +679,7 @@ async def get_profile(
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
     """Fetch a single profile's full content, shaped like
-    ``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it
+    the frontend's ``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it
     unchanged. The inner ``setting`` field is the raw slicer-format JSON
     Orca stores — same shape Bambu Cloud uses since OrcaSlicer is a
     BambuStudio fork."""

+ 3 - 100
backend/app/api/routes/printers.py

@@ -243,9 +243,10 @@ async def list_usb_cameras(
     Returns:
         List of dicts with {device: str, name: str, capabilities: list, formats?: list}
     """
-    from backend.app.services.external_camera import list_usb_cameras
+    from backend.app.services import external_camera
 
-    cameras = list_usb_cameras()
+    # v4l2-ctl runs twice per device, with a 5 s timeout each.
+    cameras = await asyncio.to_thread(external_camera.list_usb_cameras)
     return {"cameras": cameras}
 
 
@@ -1036,22 +1037,6 @@ async def disconnect_printer(
     return {"connected": False}
 
 
-@router.post("/test")
-async def test_printer_connection(
-    ip_address: str,
-    serial_number: str,
-    access_code: str,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
-):
-    """Test connection to a printer without saving."""
-    result = await printer_manager.test_connection(
-        ip_address=ip_address,
-        serial_number=serial_number,
-        access_code=access_code,
-    )
-    return result
-
-
 @router.post("/diagnostic", response_model=PrinterDiagnosticResult)
 async def diagnose_connection(
     req: DiagnosticRequest,
@@ -3549,54 +3534,6 @@ async def delete_ams_label(
     return {"success": True}
 
 
-@router.post("/{printer_id}/debug/simulate-print-complete")
-async def debug_simulate_print_complete(
-    printer_id: int,
-    db: AsyncSession = Depends(get_db),
-    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-):
-    """DEBUG: Simulate print completion to test freeze behavior.
-
-    This triggers the same code path as a real print completion,
-    without needing to wait for an actual print to finish.
-    """
-    from backend.app.main import _active_prints, on_print_complete
-    from backend.app.models.archive import PrintArchive
-
-    # Get the most recent archive for this printer
-    result = await db.execute(
-        select(PrintArchive)
-        .where(PrintArchive.printer_id == printer_id)
-        .order_by(PrintArchive.created_at.desc())
-        .limit(1)
-    )
-    archive = result.scalar_one_or_none()
-
-    if not archive:
-        raise HTTPException(status_code=404, detail="No archives found for this printer")
-
-    # Register this archive as "active" so on_print_complete can find it
-    filename = archive.file_path.split("/")[-1] if archive.file_path else "test.3mf"
-    subtask_name = archive.print_name or "Test Print"
-    _active_prints[(printer_id, filename)] = archive.id
-    _active_prints[(printer_id, subtask_name)] = archive.id
-
-    # Simulate print completion data
-    data = {
-        "status": "completed",
-        "filename": filename,
-        "subtask_name": subtask_name,
-        "timelapse_was_active": False,
-    }
-
-    logger.info("Simulating print complete for printer %s, archive %s", printer_id, archive.id)
-
-    # Call the actual on_print_complete handler
-    await on_print_complete(printer_id, data)
-
-    return {"success": True, "archive_id": archive.id, "message": "Print completion simulated"}
-
-
 # =============================================================================
 # Print Control Endpoints
 # =============================================================================
@@ -4780,40 +4717,6 @@ async def ams_unload(
     return {"success": True, "message": "Unloading filament"}
 
 
-@router.get("/{printer_id}/runtime-debug")
-async def get_runtime_debug(
-    printer_id: int,
-    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
-):
-    """Debug endpoint: Get runtime tracking status for a 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")
-
-    state = printer_manager.get_status(printer_id)
-
-    return {
-        "printer_name": printer.name,
-        "runtime_seconds": printer.runtime_seconds,
-        "runtime_hours": printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0,
-        "print_hours_offset": printer.print_hours_offset,
-        "total_hours": (printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0)
-        + (printer.print_hours_offset or 0),
-        "last_runtime_update": printer.last_runtime_update.isoformat() if printer.last_runtime_update else None,
-        "mqtt_state": {
-            "connected": state.connected if state else False,
-            "state": state.state if state else None,
-            "progress": state.progress if state else None,
-            "gcode_file": state.gcode_file if state else None,
-        }
-        if state
-        else None,
-        "is_active": printer.is_active,
-    }
-
-
 @router.post("/{printer_id}/hms/execute-action")
 async def execute_hms_action(
     printer_id: int,

+ 7 - 37
backend/app/api/routes/settings.py

@@ -190,7 +190,6 @@ _BOOL_SETTING_KEYS = frozenset(
         "include_beta_updates",
         "announcements_enabled",
         "announcements_all_users",
-        "virtual_printer_enabled",
         "ftp_retry_enabled",
         "mqtt_enabled",
         "mqtt_use_tls",
@@ -1835,35 +1834,17 @@ async def get_virtual_printer_settings(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Get virtual printer settings and status."""
-    from backend.app.services.virtual_printer import (
-        DEFAULT_VIRTUAL_PRINTER_MODEL,
-        virtual_printer_manager,
-    )
+    """Get the settings this route still sets, and the virtual printers' status.
+
+    Every other virtual printer setting lives on its own row
+    (``GET /virtual-printers``).
+    """
+    from backend.app.services.virtual_printer import virtual_printer_manager
 
-    enabled = await get_setting(db, "virtual_printer_enabled")
-    access_code = await get_setting(db, "virtual_printer_access_code")
-    mode = await get_setting(db, "virtual_printer_mode")
-    model = await get_setting(db, "virtual_printer_model")
-    target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
-    remote_interface_ip = await get_setting(db, "virtual_printer_remote_interface_ip")
     tailscale_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
     archive_name_source = await get_setting(db, "virtual_printer_archive_name_source")
 
-    from backend.app.models.virtual_printer import VP_MODE_ARCHIVE, normalize_vp_mode
-
     return {
-        "enabled": enabled == "true" if enabled else False,
-        "access_code_set": bool(access_code),
-        # Normalize on read so older settings rows (with `immediate` /
-        # `print_queue`) come out as `archive` / `queue` for the frontend.
-        "mode": normalize_vp_mode(mode) or VP_MODE_ARCHIVE,
-        "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
-        # A target the caller can't see is not named.
-        "target_printer_id": int(target_printer_id)
-        if target_printer_id and printer_scope.allows(int(target_printer_id))
-        else None,
-        "remote_interface_ip": remote_interface_ip or "",
         "tailscale_disabled": tailscale_disabled_raw == "true" if tailscale_disabled_raw else True,
         "archive_name_source": archive_name_source if archive_name_source in ("metadata", "filename") else "metadata",
         "status": _legacy_vp_status(virtual_printer_manager.get_status(), printer_scope),
@@ -1894,7 +1875,6 @@ async def update_virtual_printer_settings(
     from sqlalchemy import select
 
     from backend.app.models.virtual_printer import VirtualPrinter as FirstVP
-    from backend.app.services.virtual_printer import virtual_printer_manager
 
     current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
     current_target_id = int(current_target_id_str) if current_target_id_str else None
@@ -1941,7 +1921,7 @@ async def update_virtual_printer_settings(
 
     if tailscale_disabled is not None:
         await set_setting(db, "virtual_printer_tailscale_disabled", "true" if tailscale_disabled else "false")
-        # The running virtual printers read it from the first row.
+        # The first virtual printer's card shows its Tailscale address by it.
         if first_vp is not None:
             first_vp.tailscale_disabled = tailscale_disabled
     if archive_name_source is not None:
@@ -1950,16 +1930,6 @@ async def update_virtual_printer_settings(
     await db.commit()
     db.expire_all()
 
-    if tailscale_disabled is not None:
-        try:
-            await virtual_printer_manager.sync_from_db()
-        except Exception as e:
-            logger.error("Failed to configure virtual printer: %s", e, exc_info=True)
-            return JSONResponse(
-                status_code=500,
-                content={"detail": "Failed to configure virtual printer. Check server logs for details."},
-            )
-
     return await get_virtual_printer_settings(db, printer_scope=printer_scope)
 
 

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

@@ -38,7 +38,6 @@ from backend.app.schemas.spoolbuddy import (
     TagRemovedRequest,
     TagScannedRequest,
     UpdateSpoolWeightRequest,
-    UpdateStatusRequest,
     WriteTagRequest,
     WriteTagResultRequest,
 )
@@ -1585,41 +1584,6 @@ async def get_ssh_public_key(
         raise HTTPException(status_code=500, detail="Failed to retrieve SSH public key") from e
 
 
-@router.post("/devices/{device_id}/update-status")
-async def report_update_status(
-    device_id: str,
-    req: UpdateStatusRequest,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-):
-    """Daemon reports update progress back to the backend."""
-    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
-    device = result.scalar_one_or_none()
-    if not device:
-        raise HTTPException(status_code=404, detail="Device not registered")
-
-    device.update_status = req.status
-    device.update_message = req.message
-    # Only "complete" clears pending_command here. "error" leaves it set so the user can retry
-    # via the UI. The SSH service's own _update_progress clears on both "complete" and "error"
-    # because it owns the full update lifecycle end-to-end.
-    if req.status == "complete":
-        device.pending_command = None
-    await db.commit()
-
-    logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, req.status, req.message)
-    await ws_manager.broadcast(
-        {
-            "type": "spoolbuddy_update",
-            "device_id": device_id,
-            "update_status": req.status,
-            "update_message": req.message,
-        }
-    )
-
-    return {"status": "ok"}
-
-
 # --- Background watchdog ---
 
 

+ 0 - 17
backend/app/api/routes/support.py

@@ -710,23 +710,6 @@ async def _collect_github_backup_info(db: AsyncSession) -> dict:
     }
 
 
-async def _check_url_reachable(url: str, timeout: float = 2.0) -> bool | None:
-    """Single HEAD/GET ping with a short timeout. Returns None if URL is empty."""
-    if not url or not url.strip():
-        return None
-    try:
-        import httpx
-
-        async with httpx.AsyncClient(timeout=timeout, verify=False) as client:  # nosec B501 — local sidecars often use self-signed; this is a reachability/health probe only, no secrets are sent
-            r = await client.get(url, follow_redirects=False)
-            # Anything that returned a status code counts as reachable, even 404
-            # (the API server is up, just the path was wrong) — separates network
-            # failure from configuration mistakes for the user.
-            return r.status_code is not None
-    except Exception:
-        return False
-
-
 async def _fetch_slicer_health(url: str, timeout: float = 2.0) -> dict | None:
     """Fetch ``/health`` from a slicer sidecar and extract the CLI version.
 

+ 0 - 11
backend/app/core/auth.py

@@ -1036,17 +1036,6 @@ async def verify_websocket_token_principal(token: str) -> tuple[str, int | None]
         return row.username or "", row.api_key_id
 
 
-async def verify_websocket_token(token: str) -> str | None:
-    """Verify a WebSocket connect token.
-
-    Returns the recorded ``username`` (possibly ``""`` for API-key
-    callers, never ``None`` on success) when the token is valid, or
-    ``None`` when it is missing / expired / unknown.
-    """
-    principal = await verify_websocket_token_principal(token)
-    return None if principal is None else principal[0]
-
-
 _NO_PRINTERS = PrinterScope(frozenset())
 
 

+ 9 - 9
backend/app/core/database.py

@@ -448,9 +448,9 @@ async def _migrate_sqlite_autoincrement(conn) -> None:
             await conn.execute(text(f"DROP TABLE IF EXISTS {copy}"))
             await conn.execute(text(new_sql))
             names = ", ".join(f'"{r[1]}"' for r in await conn.execute(text(f"PRAGMA table_info({table})")))
-            await conn.execute(text(f"INSERT INTO {copy} ({names}) SELECT {names} FROM {table}"))
-            original = (await conn.execute(text(f"SELECT count(*) FROM {table}"))).scalar_one()
-            copied = (await conn.execute(text(f"SELECT count(*) FROM {copy}"))).scalar_one()
+            await conn.execute(text(f"INSERT INTO {copy} ({names}) SELECT {names} FROM {table}"))  # nosec B608 -- table from AUTOINCREMENT_TABLES, columns from its own PRAGMA
+            original = (await conn.execute(text(f"SELECT count(*) FROM {table}"))).scalar_one()  # nosec B608 -- table from AUTOINCREMENT_TABLES, columns from its own PRAGMA
+            copied = (await conn.execute(text(f"SELECT count(*) FROM {copy}"))).scalar_one()  # nosec B608 -- table from AUTOINCREMENT_TABLES, columns from its own PRAGMA
             if copied != original:
                 raise RuntimeError(f"{table} AUTOINCREMENT rebuild: {original} rows, {copied} copied")
             await conn.execute(text(f"DROP TABLE {table}"))
@@ -5985,7 +5985,7 @@ async def _migrate_pg_columns_to_model(conn) -> None:
         extra = ""
         if (table, column) == ("oidc_providers", "email_claim"):
             extra = ", auto_link_existing_accounts = false"
-        filled = await conn.execute(text(f"UPDATE {table} SET {column} = {fill}{extra} WHERE {column} IS NULL"))
+        filled = await conn.execute(text(f"UPDATE {table} SET {column} = {fill}{extra} WHERE {column} IS NULL"))  # nosec B608 -- table/column from _PG_COLUMNS_SET_FROM_MODEL, fill from the model default
         if filled.rowcount:
             logger.info("Filled %d NULL %s.%s value(s) with %s", filled.rowcount, table, column, fill)
         await _safe_execute(conn, f"ALTER TABLE {table} ALTER COLUMN {column} SET NOT NULL")
@@ -6052,13 +6052,13 @@ async def _migrate_pg_column_types(conn) -> None:
         if current != old_type:
             continue
         if new_type == "JSON":
-            rows = await conn.execute(text(f"SELECT id, {column} FROM {table} WHERE {column} IS NOT NULL"))
+            rows = await conn.execute(text(f"SELECT id, {column} FROM {table} WHERE {column} IS NOT NULL"))  # nosec B608 -- table/column from _PG_COLUMN_TYPE_FIXES
             for row_id, value in rows.all():
                 try:
                     json.loads(value, parse_constant=_reject_non_json_constant)
                 except ValueError:
                     logger.warning("Clearing %s.%s of row %s: not valid JSON", table, column, row_id)
-                    await conn.execute(text(f"UPDATE {table} SET {column} = NULL WHERE id = :id"), {"id": row_id})
+                    await conn.execute(text(f"UPDATE {table} SET {column} = NULL WHERE id = :id"), {"id": row_id})  # nosec B608 -- table/column from _PG_COLUMN_TYPE_FIXES; id bound
         # Float to text gives the shortest exact decimal only with
         # extra_float_digits >= 1, PostgreSQL's default since 12, which a server,
         # database or role setting can lower: at 0 text keeps 6 digits.
@@ -6315,7 +6315,7 @@ async def _migrate_add_missing_set_null_fks(conn) -> None:
             continue
         cleared = await conn.execute(
             text(
-                f"UPDATE {table} SET {column} = NULL WHERE {column} IS NOT NULL "
+                f"UPDATE {table} SET {column} = NULL WHERE {column} IS NOT NULL "  # nosec B608 -- table/column/parent from _MISSING_SET_NULL_FKS
                 f"AND NOT EXISTS (SELECT 1 FROM {parent} WHERE {parent}.id = {table}.{column})"
             )
         )
@@ -6975,8 +6975,8 @@ async def _migrate_normalize_printer_locations(conn) -> None:
             )
             await conn.execute(
                 text(
-                    f"UPDATE {table} SET {column} = TRIM({column}) WHERE {column} IS NOT NULL AND {column} <> TRIM({column})"
-                )  # noqa: S608  # nosec B608 — fixed identifiers
+                    f"UPDATE {table} SET {column} = TRIM({column}) WHERE {column} IS NOT NULL AND {column} <> TRIM({column})"  # noqa: S608  # nosec B608 — fixed identifiers
+                )
             )
 
 

+ 0 - 4
backend/app/core/printer_scope.py

@@ -60,10 +60,6 @@ class PrinterScope:
             return self
         return PrinterScope(self.printer_ids & other.printer_ids)
 
-    def filter_ids(self, printer_ids: Iterable[int]) -> list[int]:
-        """``printer_ids`` minus the ones out of scope, order kept."""
-        return [pid for pid in printer_ids if self.allows(pid)]
-
     def where(self, column):
         """A WHERE clause limiting ``column`` (a printer id column) to the scope.
 

+ 4 - 4
backend/app/main.py

@@ -10976,10 +10976,6 @@ async def lifespan(app: FastAPI):
                     # but the K-profile (cali_idx) did not — the reporter's exact
                     # "loaded but no flow profile" symptom.
                     "kprofile_applied": detail.get("kprofile_applied", True),
-                    # Present on failure: whether any tray telemetry was seen in
-                    # the window (distinguishes "printer silent" from "printer
-                    # stored something else").
-                    "saw_tray": detail.get("saw_tray", False),
                 }
             )
         except Exception as e:
@@ -11226,6 +11222,10 @@ async def lifespan(app: FastAPI):
     from backend.app.services.printer_media import stop_printer_download_cleanup
 
     await stop_printer_download_cleanup()
+    # Stops a timelapse editor's ffmpeg rather than leaving it to the OS.
+    from backend.app.services import timelapse_jobs
+
+    await timelapse_jobs.cancel_all()
     printer_manager.disconnect_all()
     slot_unlink_grace.reset()
     await close_spoolman_client()

+ 0 - 87
backend/app/models/auth_ephemeral.py

@@ -109,93 +109,6 @@ class AuthEphemeralToken(Base):
     # and prevent accidentally leaving optional fields at their defaults.
     # ------------------------------------------------------------------
 
-    @classmethod
-    def new_pre_auth(
-        cls,
-        token: str,
-        username: str,
-        expires_at: datetime,
-        challenge_id: str | None = None,
-    ) -> AuthEphemeralToken:
-        """Create a pre-auth token (issued after password check, before 2FA)."""
-        return cls(
-            token=token,
-            token_type=TokenType.PRE_AUTH,
-            username=username,
-            expires_at=expires_at,
-            challenge_id=challenge_id,
-        )
-
-    @classmethod
-    def new_oidc_state(
-        cls,
-        token: str,
-        provider_id: int,
-        nonce: str,
-        code_verifier: str,
-        expires_at: datetime,
-    ) -> AuthEphemeralToken:
-        """Create an OIDC state token (CSRF protection + PKCE for authorize redirect)."""
-        return cls(
-            token=token,
-            token_type=TokenType.OIDC_STATE,
-            provider_id=provider_id,
-            nonce=nonce,
-            code_verifier=code_verifier,
-            expires_at=expires_at,
-        )
-
-    @classmethod
-    def new_oidc_exchange(
-        cls,
-        token: str,
-        username: str,
-        expires_at: datetime,
-    ) -> AuthEphemeralToken:
-        """Create an OIDC exchange token (bridge from callback to SPA)."""
-        return cls(
-            token=token,
-            token_type=TokenType.OIDC_EXCHANGE,
-            username=username,
-            expires_at=expires_at,
-        )
-
-    @classmethod
-    def new_password_reset(
-        cls,
-        token: str,
-        username: str,
-        expires_at: datetime,
-    ) -> AuthEphemeralToken:
-        """Create a password-reset token (single-use link emailed to the user)."""
-        return cls(
-            token=token,
-            token_type=TokenType.PASSWORD_RESET,
-            username=username,
-            expires_at=expires_at,
-        )
-
-    @classmethod
-    def new_email_otp_setup(
-        cls,
-        token: str,
-        username: str,
-        code_hash: str,
-        expires_at: datetime,
-    ) -> AuthEphemeralToken:
-        """Create an email-OTP setup token.
-
-        The ``code_hash`` is stored in the ``nonce`` column (field reuse
-        documented inline in the enable_email_otp endpoint).
-        """
-        return cls(
-            token=token,
-            token_type=TokenType.EMAIL_OTP_SETUP,
-            username=username,
-            nonce=code_hash,
-            expires_at=expires_at,
-        )
-
     @classmethod
     def new_connect_code(
         cls,

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

@@ -254,15 +254,3 @@ class ProjectPageResponse(BaseModel):
     model_pictures: list[ProjectPageImage] = []
     profile_pictures: list[ProjectPageImage] = []
     thumbnails: list[ProjectPageImage] = []
-
-
-class ProjectPageUpdate(BaseModel):
-    """Update project page data in 3MF file."""
-
-    title: str | None = None
-    description: str | None = None
-    designer: str | None = None
-    license: str | None = None
-    copyright: str | None = None
-    profile_title: str | None = None
-    profile_description: str | None = None

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

@@ -283,12 +283,6 @@ class BackupCodesResponse(BaseModel):
     message: str
 
 
-class EmailOTPEnableRequest(BaseModel):
-    """No body required — email is taken from the authenticated user's profile."""
-
-    pass
-
-
 class TwoFAVerifyRequest(BaseModel):
     pre_auth_token: str = Field(..., max_length=128)
     # TOTP/email codes are 6 digits; backup codes are 8 uppercase alphanumeric chars.

+ 0 - 18
backend/app/schemas/cloud.py

@@ -104,24 +104,6 @@ class SlicerSettingUpdate(BaseModel):
     setting: dict | None = Field(None, description="Setting key-value pairs to update")
 
 
-class SlicerSettingDetail(BaseModel):
-    """Detailed slicer setting/preset response."""
-
-    message: str | None = None
-    code: str | None = None
-    error: str | None = None
-    public: bool = False
-    version: str | None = None
-    type: str
-    name: str
-    update_time: str | None = None
-    nickname: str | None = None
-    base_id: str | None = None
-    setting: dict = Field(default_factory=dict)
-    filament_id: str | None = None
-    setting_id: str | None = None  # For response after create
-
-
 class SlicerSettingDeleteResponse(BaseModel):
     """Response from deleting a preset."""
 

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

@@ -122,20 +122,6 @@ class FolderTreeItem(BaseModel):
 # ============ File Schemas ============
 
 
-class FileCreate(BaseModel):
-    """Schema for creating a file entry (internal use after upload)."""
-
-    filename: str
-    file_path: str
-    file_type: str
-    file_size: int
-    file_hash: str | None = None
-    thumbnail_path: str | None = None
-    metadata: dict | None = None
-    folder_id: int | None = None
-    project_id: int | None = None
-
-
 class FileUpdate(IgnoresNullForRequiredColumns):
     """Schema for updating a file."""
 

+ 0 - 5
backend/app/schemas/library_trash.py

@@ -7,11 +7,6 @@ from datetime import datetime
 from pydantic import BaseModel, Field
 
 
-class PurgePreviewRequest(BaseModel):
-    older_than_days: int = Field(ge=1, le=3650, description="Age threshold in days.")
-    include_never_printed: bool = True
-
-
 class PurgePreviewResponse(BaseModel):
     count: int
     total_bytes: int

+ 0 - 8
backend/app/schemas/maintenance.py

@@ -49,10 +49,6 @@ class PrinterMaintenanceBase(BaseModel):
     enabled: bool = True
 
 
-class PrinterMaintenanceCreate(PrinterMaintenanceBase):
-    pass
-
-
 class PrinterMaintenanceUpdate(IgnoresNullForRequiredColumns):
     __orm_table__ = "printer_maintenance"
     custom_interval_hours: float | None = None
@@ -81,10 +77,6 @@ class MaintenanceHistoryBase(BaseModel):
     notes: str | None = None
 
 
-class MaintenanceHistoryCreate(MaintenanceHistoryBase):
-    pass
-
-
 class MaintenanceHistoryResponse(MaintenanceHistoryBase):
     id: int
     printer_maintenance_id: int

+ 0 - 72
backend/app/schemas/notification.py

@@ -356,78 +356,6 @@ class NotificationTestResponse(BaseModel):
     message: str
 
 
-# Provider-specific config schemas for documentation/validation reference
-class CallMeBotConfig(BaseModel):
-    """CallMeBot/WhatsApp configuration."""
-
-    phone: str = Field(..., description="Phone number with country code (e.g., +1234567890)")
-    apikey: str = Field(..., description="API key from CallMeBot")
-
-
-class NtfyConfig(BaseModel):
-    """ntfy configuration."""
-
-    server: str = Field(default="https://ntfy.sh", description="ntfy server URL")
-    topic: str = Field(..., description="Topic name to publish to")
-    auth_token: str | None = Field(default=None, description="Optional authentication token")
-    event_priorities: dict[str, int] | None = Field(
-        default=None,
-        description=(
-            "Per-event priority override. Keys are event names, either the provider's "
-            "toggle column ('on_print_failed', what the UI writes) or the bare event "
-            "name ('print_failed'); both are accepted. Values are ntfy priorities 1-5 "
-            "(1=min, 2=low, 3=default, 4=high, 5=urgent). Events without an entry use "
-            "ntfy's server-side default."
-        ),
-    )
-
-
-class GotifyConfig(BaseModel):
-    """Gotify configuration (#2743)."""
-
-    server: str = Field(..., description="Gotify server URL")
-    app_token: str = Field(..., description="Token of the Gotify application to post as")
-    event_priorities: dict[str, int] | None = Field(
-        default=None,
-        description=(
-            "Per-event priority override, keyed like NtfyConfig.event_priorities. Values are "
-            "levels 1-5 (min, low, default, high, urgent), sent to Gotify as 0, 2, 5, 8 and 10. "
-            "Events without an entry are sent at 5."
-        ),
-    )
-
-
-class PushoverConfig(BaseModel):
-    """Pushover configuration."""
-
-    user_key: str = Field(..., description="Your Pushover user key")
-    app_token: str = Field(..., description="Your Pushover application token")
-    priority: int = Field(default=0, ge=-2, le=2, description="Message priority (-2 to 2)")
-    # Emergency priority (2) only: how often to re-alert and when to stop.
-    # Pushover requires retry >= 30s and expire <= 10800s (3h).
-    retry: int = Field(default=60, ge=30, le=10800, description="Emergency re-alert interval in seconds (priority 2)")
-    expire: int = Field(default=3600, ge=30, le=10800, description="Emergency alert expiry in seconds (priority 2)")
-
-
-class TelegramConfig(BaseModel):
-    """Telegram bot configuration."""
-
-    bot_token: str = Field(..., description="Bot token from @BotFather")
-    chat_id: str = Field(..., description="Chat ID to send messages to")
-
-
-class EmailConfig(BaseModel):
-    """Email/SMTP configuration."""
-
-    smtp_server: str = Field(..., description="SMTP server hostname")
-    smtp_port: int = Field(default=587, description="SMTP port (587 for TLS, 465 for SSL)")
-    username: str = Field(..., description="SMTP username/email")
-    password: str = Field(..., description="SMTP password or app password")
-    from_email: str = Field(..., description="From email address")
-    to_email: str = Field(..., description="Recipient email address")
-    use_tls: bool = Field(default=True, description="Use TLS encryption")
-
-
 # Notification Log schemas
 class NotificationLogResponse(BaseModel):
     """Schema for notification log API responses."""

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

@@ -500,13 +500,6 @@ class NotificationTemplateResponse(NotificationTemplateBase):
         from_attributes = True
 
 
-class TemplateVariableInfo(BaseModel):
-    """Information about a template variable."""
-
-    name: str
-    description: str
-
-
 class EventVariablesResponse(BaseModel):
     """Response for available variables per event type."""
 

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

@@ -74,7 +74,7 @@ class OrcaProfileListResponse(BaseModel):
 
 
 class OrcaProfileDetail(BaseModel):
-    """Single profile's full content, shaped to match ``SlicerSettingDetail``
+    """Single profile's full content, shaped to match the frontend's ``SlicerSettingDetail``
     so the frontend's detail modal can render it without translation."""
 
     setting_id: str

+ 0 - 44
backend/app/schemas/printer.py

@@ -4,7 +4,6 @@ from pydantic import BaseModel, Field, field_validator, model_validator
 
 from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.printer_location import normalize_location_name
-from backend.app.utils.printer_models import supports_nozzle_flow_type
 
 
 def _mask_url_login(url: str | None) -> str | None:
@@ -136,49 +135,6 @@ class PrinterResponse(PrinterBase):
     class Config:
         from_attributes = True
 
-    @classmethod
-    def from_orm_with_roi(cls, printer) -> "PrinterResponse":
-        """Create response from ORM model, converting ROI fields to nested object."""
-        data = {
-            "id": printer.id,
-            "name": printer.name,
-            "serial_number": printer.serial_number,
-            "ip_address": printer.ip_address,
-            "model": printer.model,
-            "location": printer.location,
-            "auto_archive": printer.auto_archive,
-            "external_camera_url": printer.external_camera_url,
-            "external_camera_type": printer.external_camera_type,
-            "external_camera_enabled": printer.external_camera_enabled,
-            "external_camera_snapshot_url": printer.external_camera_snapshot_url,
-            "camera_rotation": printer.camera_rotation,
-            "camera_light_auto": bool(printer.camera_light_auto),
-            "is_active": printer.is_active,
-            "nozzle_count": printer.nozzle_count,
-            "supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
-            "print_hours_offset": printer.print_hours_offset,
-            "wear_cost_per_hour": printer.wear_cost_per_hour,
-            "plate_detection_enabled": printer.plate_detection_enabled,
-            "created_at": printer.created_at,
-            "updated_at": printer.updated_at,
-        }
-        # Build ROI object if any ROI field is set
-        if any(
-            [
-                printer.plate_detection_roi_x is not None,
-                printer.plate_detection_roi_y is not None,
-                printer.plate_detection_roi_w is not None,
-                printer.plate_detection_roi_h is not None,
-            ]
-        ):
-            data["plate_detection_roi"] = PlateDetectionROI(
-                x=printer.plate_detection_roi_x or 0.15,
-                y=printer.plate_detection_roi_y or 0.35,
-                w=printer.plate_detection_roi_w or 0.70,
-                h=printer.plate_detection_roi_h or 0.55,
-            )
-        return cls(**data)
-
     @field_validator("external_camera_url", "external_camera_snapshot_url", mode="after")
     @classmethod
     def _camera_credentials(cls, value: str | None) -> str | None:

+ 0 - 19
backend/app/schemas/project.py

@@ -304,25 +304,6 @@ class LinkedFolderExport(BaseModel):
     name: str
 
 
-class ProjectExport(BaseModel):
-    """Schema for exporting a project."""
-
-    name: str
-    description: str | None
-    color: str | None
-    status: str
-    target_count: int | None
-    target_parts_count: int | None
-    target_sets: int | None = None
-    notes: str | None
-    tags: str | None
-    due_date: datetime | None
-    priority: str
-    budget: float | None
-    bom_items: list[BOMItemExport] = []
-    linked_folders: list[LinkedFolderExport] = []
-
-
 class ProjectImport(BaseModel):
     """Schema for importing a project."""
 

+ 0 - 9
backend/app/schemas/settings.py

@@ -233,12 +233,6 @@ class AppSettings(BaseModel):
     )
 
     # Virtual Printer
-    virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
-    virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
-    virtual_printer_mode: str = Field(
-        default="archive",
-        description="Mode: 'archive' (archive now), 'review' (pending review), 'queue' (add to print queue), or 'proxy' (relay to real printer)",
-    )
     virtual_printer_archive_name_source: str = Field(
         default="metadata",
         description="Source for the archive's display name on virtual-printer uploads: 'metadata' uses the 3MF's embedded print_name (default, matches Bambu's behavior), 'filename' uses the filename Bambu Studio sent over FTP (lets users rename via the slicer's 'send to printer' dialog).",
@@ -773,9 +767,6 @@ class AppSettingsUpdate(BaseModel):
     time_format: str | None = None
     default_printer_id: int | None = None
     pipeline_max_copies: int | None = None
-    virtual_printer_enabled: bool | None = None
-    virtual_printer_access_code: str | None = None
-    virtual_printer_mode: str | None = None
     virtual_printer_archive_name_source: str | None = None
     dark_style: str | None = None
     dark_background: str | None = None

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

@@ -1,6 +1,5 @@
 import json
 from datetime import datetime
-from typing import Literal
 
 from pydantic import BaseModel, Field, field_validator
 
@@ -95,7 +94,6 @@ class TagScannedRequest(BaseModel):
     tray_uuid: str | None = Field(None, max_length=32, pattern=r"^[0-9A-Fa-f]*$")
     sak: int | None = None
     tag_type: str | None = Field(None, max_length=50)
-    raw_blocks: dict | None = None
 
 
 class TagRemovedRequest(BaseModel):
@@ -174,11 +172,6 @@ class SystemCommandResultRequest(BaseModel):
     message: str | None = Field(None, max_length=500)
 
 
-class UpdateStatusRequest(BaseModel):
-    status: Literal["updating", "complete", "error"]
-    message: str | None = Field(None, max_length=255)
-
-
 # --- Diagnostics schemas ---
 
 

+ 18 - 6
backend/app/schemas/timelapse.py

@@ -22,9 +22,21 @@ class ThumbnailResponse(BaseModel):
     timestamps: list[float] = Field(description="Timestamp for each thumbnail in seconds")
 
 
-class ProcessResponse(BaseModel):
-    """Processing result response."""
-
-    status: str = Field(description="Processing status: completed, error")
-    output_path: str | None = Field(default=None, description="Relative path to output file")
-    message: str = Field(description="Status message")
+class TimelapseJobResponse(BaseModel):
+    """A timelapse re-encode job."""
+
+    status: str = Field(description="running, completed, failed or cancelled")
+    progress: float | None = Field(default=None, description="Share done, 0.0 to 1.0; null before the first report")
+    output_path: str | None = Field(default=None, description="Relative path to the output file, once completed")
+    message: str | None = Field(default=None, description="Result message, once completed")
+    error: str | None = Field(default=None, description="Why it failed")
+
+    @classmethod
+    def from_job(cls, job) -> "TimelapseJobResponse":
+        return cls(
+            status=job.status,
+            progress=job.progress,
+            output_path=job.output_path,
+            message=job.message,
+            error=job.error,
+        )

+ 0 - 6
backend/app/services/bambu_cloud.py

@@ -778,12 +778,6 @@ class BambuCloudService:
         self.access_token = access_token
         self.token_expiry = None
 
-    def logout(self):
-        """Clear authentication state."""
-        self.access_token = None
-        self.refresh_token = None
-        self.token_expiry = None
-
     async def get_user_profile(self) -> dict:
         """Get user profile information."""
         if not self.is_authenticated:

+ 0 - 135
backend/app/services/bambu_ftp.py

@@ -605,12 +605,6 @@ class BambuFTPClient:
         # can say how long the session was held (#3009).
         self._connected_at: float | None = None
 
-    def _is_a1_model(self) -> bool:
-        """Check if this is an A1 series printer."""
-        if not self.printer_model:
-            return False
-        return self.printer_model in self.A1_MODELS
-
     def _get_cached_mode(self) -> str | None:
         """Get cached FTP mode for this printer."""
         return self._mode_cache.get(self.ip_address)
@@ -1093,62 +1087,6 @@ class BambuFTPClient:
             logger.info("FTP download failed for %s: %s", remote_path, e)
             return False
 
-    def diagnose_storage(self) -> dict:
-        """Run storage diagnostics and return results. For debugging upload issues."""
-        results = {
-            "connected": self._ftp is not None,
-            "can_list_root": False,
-            "root_files": [],
-            "can_list_cache": False,
-            "storage_info": None,
-            "pwd": None,
-            "errors": [],
-        }
-
-        if not self._ftp:
-            results["errors"].append("FTP not connected")
-            return results
-
-        # Try to get current directory
-        try:
-            results["pwd"] = self._ftp.pwd()
-            logger.debug("FTP current directory: %s", results["pwd"])
-        except (OSError, ftplib.Error) as e:
-            results["errors"].append(f"PWD failed: {e}")
-            logger.debug("FTP PWD failed: %s", e)
-
-        # Try to list root directory
-        try:
-            self._ftp.cwd("/")
-            items = []
-            self._ftp.retrlines("LIST", items.append)
-            results["can_list_root"] = True
-            results["root_files"] = items[:10]  # First 10 entries
-            logger.debug("FTP root listing (%s items): %s", len(items), items[:5])
-        except (OSError, ftplib.Error) as e:
-            results["errors"].append(f"LIST / failed: {e}")
-            logger.debug("FTP LIST / failed: %s", e)
-
-        # Try to list /cache (should exist on all printers)
-        try:
-            self._ftp.cwd("/cache")
-            items = []
-            self._ftp.retrlines("LIST", items.append)
-            results["can_list_cache"] = True
-            logger.debug("FTP /cache listing: %s items", len(items))
-        except (OSError, ftplib.Error) as e:
-            results["errors"].append(f"LIST /cache failed: {e}")
-            logger.debug("FTP LIST /cache failed: %s", e)
-
-        # Try to get storage info
-        try:
-            results["storage_info"] = self.get_storage_info()
-            logger.debug("FTP storage info: %s", results["storage_info"])
-        except (OSError, ftplib.Error) as e:
-            results["errors"].append(f"Storage info failed: {e}")
-
-        return results
-
     def upload_file(
         self,
         local_path: Path,
@@ -1353,79 +1291,6 @@ class BambuFTPClient:
             self.last_failure = FtpFailure(kind, str(e), code)
             return False
 
-    def upload_bytes(self, data: bytes, remote_path: str) -> bool:
-        """Upload bytes to the printer."""
-        if not self._ftp:
-            return False
-
-        try:
-            # Use manual transfer instead of storbinary() for A1 compatibility
-            conn = self._ftp.transfercmd(f"STOR {remote_path}")
-            conn.setblocking(True)
-            conn.settimeout(self.timeout)
-
-            try:
-                # Send data in chunks
-                offset = 0
-                while offset < len(data):
-                    chunk = data[offset : offset + self.CHUNK_SIZE]
-                    conn.sendall(chunk)
-                    offset += len(chunk)
-            except OSError as e:
-                logger.error("FTP connection lost during upload_bytes: %s", e)
-                raise
-            finally:
-                try:
-                    conn.close()
-                except OSError:
-                    pass
-            # Wait for 226 confirmation (see upload_file for rationale).
-            # ftplib.Error subclasses (e.g. 426 error_temp) mean the server
-            # rejected the transfer and the file is partial — fail. Other
-            # exceptions (timeout, socket-level) are tolerated as in upload_file.
-            try:
-                old_timeout = self._ftp.sock.gettimeout()
-                self._ftp.sock.settimeout(max(self.timeout, 60))
-                try:
-                    self._ftp.voidresp()
-                finally:
-                    self._ftp.sock.settimeout(old_timeout)
-            except ftplib.Error as e:
-                # Same SIZE-verify path as upload_file (#1417 follow-up):
-                # tolerate a transient 426 if the bytes are actually on the
-                # printer, fail loudly if they aren't.
-                try:
-                    server_size = self._ftp.size(remote_path)
-                except (OSError, ftplib.Error) as size_err:
-                    logger.debug("Post-error SIZE check failed: %s", size_err)
-                    server_size = None
-                if server_size is not None and server_size == len(data):
-                    # INFO for the same reason as upload_file above (#2987).
-                    logger.info(
-                        "FTP STOR returned %s for %s but file is intact on the "
-                        "printer (%s bytes match) — proceeding: %s",
-                        type(e).__name__,
-                        remote_path,
-                        len(data),
-                        e,
-                    )
-                else:
-                    logger.error(
-                        "FTP STOR rejected by printer for %s: %s (%s); server size=%s expected=%s",
-                        remote_path,
-                        e,
-                        type(e).__name__,
-                        server_size,
-                        len(data),
-                    )
-                    return False
-            except Exception:
-                # Timeout / socket-level — proceed, data was sent.
-                self._check_unconfirmed_upload(remote_path, len(data))
-            return True
-        except (OSError, ftplib.Error):
-            return False
-
     def _check_unconfirmed_upload(self, remote_path: str, expected_size: int) -> None:
         """Read an upload's size back when its 226 never came.
 

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

@@ -2090,8 +2090,8 @@ class BambuMQTTClient:
                 # never show -- which is why "what does Studio put in the drying
                 # command?" had no answer from a user's log (#2774). Filed as
                 # "out" so the direction filter groups it with our own commands
-                # rather than with printer telemetry; anything sent through
-                # send_command lands twice, once on publish and once on the
+                # rather than with printer telemetry; anything we send
+                # lands twice, once on publish and once on the
                 # broker's echo, and the pair is itself evidence the command
                 # reached the broker.
                 if self._logging_enabled:
@@ -6819,21 +6819,6 @@ class BambuMQTTClient:
             # The callers drop the client from the manager anyway, so the next
             # status read already shows it gone.
 
-    def send_command(self, command: dict):
-        """Send a command to the printer."""
-        if self._client and self.state.connected:
-            # Log outgoing message if logging is enabled
-            if self._logging_enabled:
-                self._message_log.append(
-                    MQTTLogEntry(
-                        timestamp=datetime.now(timezone.utc).isoformat(),
-                        topic=self.topic_publish,
-                        direction="out",
-                        payload=command,
-                    )
-                )
-            self._client.publish(self.topic_publish, json.dumps(command), qos=1)
-
     def enable_logging(self, enabled: bool = True):
         """Enable or disable MQTT message logging."""
         self._logging_enabled = enabled
@@ -6873,7 +6858,7 @@ class BambuMQTTClient:
         """Publish a pre-formed payload directly to the printer's MQTT broker.
 
         Used by the VP MQTT bridge to forward slicer-originated commands without
-        going through send_command's sequence-id mangling. Returns False if the
+        giving it one of our own sequence ids. Returns False if the
         underlying paho client isn't ready.
         """
         if self._client is None:

+ 17 - 10
backend/app/services/external_camera.py

@@ -171,17 +171,27 @@ def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "
         return None
 
 
-def list_usb_cameras() -> list[dict]:
+# The device names a USB camera can stream from: _safe_usb_device_path
+# accepts /dev/video0 to /dev/video99, written without a leading zero.
+_USB_DEVICE_NAME = re.compile(r"^video([0-9]|[1-9][0-9])$")
+
+
+def list_usb_cameras(dev_dir: Path = Path("/dev")) -> list[dict]:
     """List available USB cameras (V4L2 devices on Linux).
 
+    Blocking: runs v4l2-ctl for each device, so call it from a thread.
+
     Returns:
         List of dicts with {device: str, name: str, capabilities: list}
     """
     cameras = []
-    video_devices = sorted(Path("/dev").glob("video*"))
+    video_devices = sorted(
+        (device for device in dev_dir.glob("video*") if _USB_DEVICE_NAME.match(device.name)),
+        key=lambda device: int(device.name.removeprefix("video")),
+    )
 
     for device in video_devices:
-        device_path = str(device)
+        device_path = f"/dev/{device.name}"
         info = {"device": device_path, "name": device.name, "capabilities": []}
 
         # Try to get device info via v4l2-ctl
@@ -222,13 +232,10 @@ def list_usb_cameras() -> list[dict]:
 
         # Only include devices that look like video capture devices
         # Skip metadata devices (typically odd numbered like video1, video3)
-        try:
-            device_num = int(device.name.replace("video", ""))
-            # Even numbered devices are usually capture, odd are metadata
-            # But also check if we got capabilities
-            if info.get("capabilities") or device_num % 2 == 0:
-                cameras.append(info)
-        except ValueError:
+        device_num = int(device.name.removeprefix("video"))
+        # Even numbered devices are usually capture, odd are metadata
+        # But also check if we got capabilities
+        if info.get("capabilities") or device_num % 2 == 0:
             cameras.append(info)
 
     return cameras

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

@@ -8,7 +8,7 @@ import logging
 from datetime import datetime, timedelta, timezone
 
 import httpx
-from sqlalchemy import desc, or_, select
+from sqlalchemy import or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import async_session
@@ -964,18 +964,6 @@ class GitHubBackupService:
         """Get current backup progress message."""
         return self._backup_progress
 
-    async def get_logs(self, config_id: int, limit: int = 50, offset: int = 0) -> list[GitHubBackupLog]:
-        """Get backup logs for a configuration."""
-        async with async_session() as db:
-            result = await db.execute(
-                select(GitHubBackupLog)
-                .where(GitHubBackupLog.config_id == config_id)
-                .order_by(desc(GitHubBackupLog.started_at))
-                .offset(offset)
-                .limit(limit)
-            )
-            return list(result.scalars().all())
-
 
 # Singleton instance
 github_backup_service = GitHubBackupService()

+ 0 - 5
backend/app/services/github_restore.py

@@ -348,10 +348,6 @@ def _checked_setting(key: str, value: object) -> tuple[bool | None, str]:
 #   * the other four switch an integration on with no way to authenticate to it,
 #     which breaks the login path (LDAP) or the connection (MQTT, HA).
 #
-# virtual_printer_enabled is largely vestigial post-migration — core/database.py
-# copies the rows into the virtual_printers table — but it is the same shape, and
-# refusing a vestigial toggle is a harmless no-op.
-#
 # ldap_enabled is deliberately NOT here. It was, paired with
 # ldap_bind_password — but this rule judges availability ("will the integration
 # work?"), and that is the wrong question for an authentication source. An
@@ -363,7 +359,6 @@ _COMPANION_CREDENTIALS = {
     "prometheus_enabled": "prometheus_token",
     "mqtt_enabled": "mqtt_password",
     "ha_enabled": "ha_token",
-    "virtual_printer_enabled": "virtual_printer_access_code",
 }
 
 # Companion credentials a reader takes from the environment rather than from a

+ 0 - 5
backend/app/services/layer_timelapse.py

@@ -401,11 +401,6 @@ def cancel_session(printer_id: int):
         logger.info("Cancelled timelapse session for printer %s", printer_id)
 
 
-def get_active_sessions() -> dict[int, TimelapseSession]:
-    """Get all active timelapse sessions."""
-    return _active_sessions.copy()
-
-
 def cleanup_orphaned_timelapse_sessions(min_age_seconds: float = 300) -> int:
     """Remove timelapse_frames/<printer_id>/* left behind by a crash or
     restart that happened while a session was active.

+ 2 - 3
backend/app/services/location_service.py

@@ -20,9 +20,8 @@ logger = logging.getLogger(__name__)
 DUPLICATE_LOCATION_NAME = "A location with this name already exists"
 
 # AMS residency markers, not storage locations. Bambuddy used to write the slot
-# a spool was loaded into -- "<printer> - AMS A1", the shape
-# `SpoolmanClient.convert_ams_slot_to_location` still produces -- straight into
-# Spoolman's `location` field. That writer went away when Storage Location
+# a spool was loaded into -- "<printer> - AMS A1", "<printer> - AMS-HT A1" or
+# "<printer> - External Spool" -- straight into Spoolman's `location` field. That writer went away when Storage Location
 # became a place the user chooses (#1114), but the strings survive on people's
 # Spoolman spools, and importing them offers a printer slot as somewhere to put
 # a spool away. A slot is where a spool is loaded, not where it is stored, and

+ 0 - 4
backend/app/services/mqtt_smart_plug.py

@@ -69,10 +69,6 @@ class MQTTSmartPlugService:
         """Check if the MQTT service is configured and connected."""
         return self._configured and self.connected
 
-    def has_broker_settings(self) -> bool:
-        """Check if broker settings are available (even if not connected yet)."""
-        return bool(self._broker)
-
     async def configure(self, settings: dict) -> bool:
         """Configure MQTT connection from settings.
 

+ 0 - 3
backend/app/services/notify_client.py

@@ -291,9 +291,6 @@ class NotifyClient:
     async def update_widget(self, widget_id: str, token: str, content: dict) -> dict:
         return await self._request("POST", f"/widgets/{self._widget_id(widget_id)}", token, content=content)
 
-    async def get_widget(self, widget_id: str, token: str) -> dict:
-        return await self._request("GET", f"/widgets/{self._widget_id(widget_id)}", token)
-
     async def list_widgets(self, device_id: str, token: str) -> dict:
         result = await self._request("GET", f"/widgets/{self._id(device_id)}", token)
         widgets = result.get("widgets")

+ 4 - 49
backend/app/services/printer_manager.py

@@ -1036,51 +1036,6 @@ class PrinterManager:
             return self._clients[printer_id].stop_print()
         return False
 
-    async def wait_for_cooldown(
-        self,
-        printer_id: int,
-        target_temp: float = 50.0,
-        timeout: int = 600,
-        check_interval: int = 10,
-    ) -> bool:
-        """Wait for the nozzle to cool down to a safe temperature.
-
-        Args:
-            printer_id: The printer to monitor
-            target_temp: Target temperature to wait for (default 50°C)
-            timeout: Maximum seconds to wait (default 600s = 10 min)
-            check_interval: Seconds between temperature checks (default 10s)
-
-        Returns:
-            True if cooled down, False if timeout or not connected
-        """
-        import logging
-
-        logger = logging.getLogger(__name__)
-
-        elapsed = 0
-        while elapsed < timeout:
-            state = self.get_status(printer_id)
-            if not state or not state.connected:
-                logger.warning("Printer %s disconnected during cooldown wait", printer_id)
-                return False
-
-            # Check nozzle temperature (and nozzle_2 for dual extruders)
-            nozzle_temp = state.temperatures.get("nozzle", 0)
-            nozzle_2_temp = state.temperatures.get("nozzle_2", 0)
-            max_temp = max(nozzle_temp, nozzle_2_temp)
-
-            if max_temp <= target_temp:
-                logger.info("Printer %s cooled down to %s°C", printer_id, max_temp)
-                return True
-
-            logger.debug("Printer %s nozzle at %s°C, waiting for %s°C...", printer_id, max_temp, target_temp)
-            await asyncio.sleep(check_interval)
-            elapsed += check_interval
-
-        logger.warning("Printer %s cooldown timeout after %ss", printer_id, timeout)
-        return False
-
     def enable_logging(self, printer_id: int, enabled: bool = True) -> bool:
         """Enable or disable MQTT logging for a printer."""
         if printer_id in self._clients:
@@ -1861,10 +1816,10 @@ def printer_state_to_dict(
         result["cover_url"] = f"/api/v1/printers/{printer_id}/cover"
     else:
         result["cover_url"] = None
-    # Surface the display name + model so WS consumers (gcode viewer printer
-    # selector) can render proper labels on the initial snapshot without racing
-    # a separate /api/v1/printers fetch (#963 follow-up). PrinterInfo only
-    # carries name/serial_number; the model comes through via the `model` arg.
+    # The display name + model, so a WS consumer can label the printer from the
+    # snapshot alone (#963 follow-up; the SPA itself takes them from
+    # /api/v1/printers). PrinterInfo only carries name/serial_number; the model
+    # comes through via the `model` arg.
     if printer_id:
         _printer_info = printer_manager.get_printer(printer_id)
         if _printer_info is not None:

+ 0 - 1
backend/app/services/secret_fields.py

@@ -121,7 +121,6 @@ SETTINGS_SECRETS: dict[str, tuple[str, ...]] = {
     "ha_token": ("ha_url",),
     "obico_ml_token": ("obico_ml_url",),
     "prometheus_token": (),
-    "virtual_printer_access_code": (),
     "ldap_bind_password": ("ldap_server_url", "ldap_security"),
 }
 

+ 0 - 11
backend/app/services/slicer_api.py

@@ -408,17 +408,6 @@ class SlicerApiService:
     async def __aexit__(self, *_: object) -> None:
         await self.close()
 
-    async def health(self) -> dict:
-        """GET /health — used to surface a clear "sidecar offline" error before
-        accepting a slice request from the user."""
-        try:
-            response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
-        except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
-        if response.status_code >= 400:
-            raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
-        return response.json()
-
     async def resolve_profile(self, profile_json: str, category: str) -> "ResolvedProfile":
         """POST /profiles/resolve — flatten a preset's ``inherits:`` chain.
 

+ 2 - 12
backend/app/services/smart_plug_manager.py

@@ -132,7 +132,6 @@ class SmartPlugManager:
 
     def __init__(self):
         self._pending_off: dict[int, asyncio.Task] = {}  # plug_id -> task
-        self._loop: asyncio.AbstractEventLoop | None = None
         self._scheduler_task: asyncio.Task | None = None
         self._snapshot_task: asyncio.Task | None = None
         self._power_alert_task: asyncio.Task | None = None
@@ -171,10 +170,6 @@ class SmartPlugManager:
         except Exception as e:
             logger.warning("Failed to configure HA service: %s", e)
 
-    def set_event_loop(self, loop: asyncio.AbstractEventLoop):
-        """Set the event loop for async operations."""
-        self._loop = loop
-
     def start_scheduler(self):
         """Start the background scheduler for time-based plug control."""
         if self._scheduler_task is None:
@@ -530,8 +525,8 @@ class SmartPlugManager:
         so unlike :meth:`on_print_complete` this does NOT gate on ``plug.auto_off``.
         It still honours ``enabled`` and skips HA-script entities (which can only
         be triggered, not turned off), and uses each plug's configured off
-        strategy via :meth:`_schedule_off_per_mode`. Replaces the three inline
-        ``wait_for_cooldown(50°C, 600s)`` blocks that ignored plug settings,
+        strategy via :meth:`_schedule_off_per_mode`. Replaces three inline
+        cooldown waits (50°C, 600s) that ignored plug settings,
         fired on the cooldown *timeout* regardless of print state, and could not
         be cancelled by a re-print (#1890).
         """
@@ -902,11 +897,6 @@ class SmartPlugManager:
         if task is not None:
             task.cancel()
 
-    def cancel_all_pending(self):
-        """Cancel all pending turn-off tasks."""
-        for plug_id in list(self._pending_off.keys()):
-            self._cancel_pending_off(plug_id)
-
     async def resume_pending_auto_offs(self):
         """Resume any pending auto-offs that were interrupted by a restart.
 

+ 0 - 72
backend/app/services/spoolman.py

@@ -17,8 +17,6 @@ from backend.app.utils.color_utils import color_match_key, spoolman_color_hex
 
 logger = logging.getLogger(__name__)
 
-BAMBU_RFID_TAG_LENGTH = 32
-
 # How long a Spoolman without native tags is believed before it is asked again,
 # so upgrading Spoolman to 0.27 needs no Bambuddy restart.
 TAG_API_RECHECK_SECONDS = 600
@@ -1093,63 +1091,6 @@ class SpoolmanClient:
                         return spool
         return None
 
-    async def find_spools_by_location_prefix(
-        self, location_prefix: str, cached_spools: list[dict] | None = None
-    ) -> list[dict]:
-        """Return all spools whose location starts with location_prefix."""
-        # Use cached spools if provided, otherwise fetch from API
-        spools = cached_spools if cached_spools is not None else await self.get_spools()
-        matching = []
-        for spool in spools:
-            location = spool.get("location", "")
-            if location and location.startswith(location_prefix):
-                matching.append(spool)
-        return matching
-
-    async def clear_location_for_removed_spools(
-        self,
-        printer_name: str,
-        current_tray_uuids: set[str],
-        cached_spools: list[dict] | None = None,
-        synced_spool_ids: set[int] | None = None,
-    ) -> int:
-        """Clear location for Bambu Lab spools at this printer whose tray_uuid is no longer in the AMS."""
-        location_prefix = f"{printer_name} - "
-        spools_at_printer = await self.find_spools_by_location_prefix(location_prefix, cached_spools=cached_spools)
-        cleared_count = 0
-
-        for spool in spools_at_printer:
-            spool_id = spool.get("id")
-
-            # Skip spools that were just synced (matched by location or tag)
-            if synced_spool_ids and spool_id in synced_spool_ids:
-                continue
-
-            # Get the tray_uuid (stored as "tag" in extra field)
-            extra = spool.get("extra", {}) or {}
-            stored_tag = extra.get("tag", "")
-            if stored_tag:
-                # Normalize: strip quotes and uppercase
-                spool_uuid = stored_tag.strip('"').upper()
-            else:
-                spool_uuid = ""
-
-            # Only clear location for Bambu Lab spools (those with a stored 32-character RFID tag).
-            if len(spool_uuid) != BAMBU_RFID_TAG_LENGTH:
-                continue
-
-            # If this spool's UUID is not in the current AMS, clear its location
-            if spool_uuid not in current_tray_uuids:
-                logger.info(
-                    f"Clearing location for spool {spool_id} "
-                    f"(was: {spool.get('location')}, uuid: {spool_uuid[:16] if spool_uuid else 'none'}...)"
-                )
-                result = await self.update_spool(spool_id=spool_id, clear_location=True)
-                if result:
-                    cleared_count += 1
-
-        return cleared_count
-
     async def ensure_bambu_vendor(self) -> int | None:
         """Return the Bambu Lab vendor ID in Spoolman, creating the vendor if absent."""
         vendors = await self.get_vendors()
@@ -1349,19 +1290,6 @@ class SpoolmanClient:
             tray_weight=int(tray_data.get("tray_weight", 1000)),
         )
 
-    def convert_ams_slot_to_location(self, ams_id: int, tray_id: int) -> str:
-        """Return a human-readable location string (e.g. "AMS A1") for the given AMS slot."""
-        if ams_id >= 254:
-            return "External Spool"
-
-        if 128 <= ams_id <= 135:
-            # AMS-HT units use IDs 128-135
-            ht_letter = chr(ord("A") + (ams_id - 128))
-            return f"AMS-HT {ht_letter}{tray_id + 1}"
-
-        ams_letter = chr(ord("A") + ams_id)
-        return f"AMS {ams_letter}{tray_id + 1}"
-
     def is_bambu_lab_spool(self, tray_uuid: str, tag_uid: str = "", tray_info_idx: str = "") -> bool:
         """Return True if tray_uuid or tag_uid identifies a Bambu Lab spool; tray_info_idx is ignored."""
         # Check tray_uuid (preferred - consistent across printer models)

+ 0 - 3
backend/app/services/telegram_reactions.py

@@ -174,9 +174,6 @@ class TelegramReactionPoller:
                     ", ".join(str(i) for i in sorted(provider_ids)),
                 )
 
-    def is_polling(self, provider_id: int) -> bool:
-        return any(provider_id in ids for token, ids in self._providers.items() if token in self._tasks)
-
     # ------------------------------------------------------------------
     # Plumbing
     # ------------------------------------------------------------------

+ 89 - 0
backend/app/services/timelapse_jobs.py

@@ -0,0 +1,89 @@
+"""Timelapse editor re-encodes, run as background jobs.
+
+Re-encoding a long timelapse takes minutes. Run inside the request, a browser
+or proxy that gave up left ffmpeg running with nobody waiting for the result.
+A job runs on its own instead: the editor polls its progress, and cancelling
+it stops ffmpeg. One job per archive at a time; the last one is kept so a
+reopened editor can show how it ended.
+"""
+
+import asyncio
+import contextlib
+import logging
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+
+logger = logging.getLogger(__name__)
+
+RUNNING = "running"
+COMPLETED = "completed"
+FAILED = "failed"
+CANCELLED = "cancelled"
+
+
+@dataclass
+class TimelapseJob:
+    archive_id: int
+    status: str = RUNNING
+    # 0.0 to 1.0; None until ffmpeg reports its first position.
+    progress: float | None = None
+    output_path: str | None = None
+    message: str | None = None
+    error: str | None = None
+    task: asyncio.Task | None = field(default=None, repr=False)
+
+    def set_progress(self, progress: float) -> None:
+        self.progress = min(max(progress, 0.0), 1.0)
+
+
+class JobAlreadyRunning(Exception):
+    pass
+
+
+# Runs the re-encode; returns (output path relative to the data dir, message).
+JobRunner = Callable[[TimelapseJob], Awaitable[tuple[str, str]]]
+
+_jobs: dict[int, TimelapseJob] = {}
+
+
+def get_job(archive_id: int) -> TimelapseJob | None:
+    return _jobs.get(archive_id)
+
+
+def start_job(archive_id: int, run: JobRunner) -> TimelapseJob:
+    current = _jobs.get(archive_id)
+    if current is not None and current.status == RUNNING:
+        raise JobAlreadyRunning
+    job = TimelapseJob(archive_id=archive_id)
+    _jobs[archive_id] = job
+    job.task = asyncio.create_task(_run(job, run))
+    return job
+
+
+async def _run(job: TimelapseJob, run: JobRunner) -> None:
+    try:
+        job.output_path, job.message = await run(job)
+        job.progress = 1.0
+        job.status = COMPLETED
+    except asyncio.CancelledError:
+        job.status = CANCELLED
+    except Exception as e:
+        logger.error("Timelapse processing failed for archive %s: %s", job.archive_id, e)
+        job.error = str(e)
+        job.status = FAILED
+
+
+async def cancel_job(archive_id: int) -> bool:
+    """Cancel the archive's running job; False when none is running."""
+    job = _jobs.get(archive_id)
+    if job is None or job.status != RUNNING or job.task is None:
+        return False
+    job.task.cancel()
+    with contextlib.suppress(asyncio.CancelledError):
+        await job.task
+    return True
+
+
+async def cancel_all() -> None:
+    for archive_id in list(_jobs):
+        await cancel_job(archive_id)

+ 82 - 3
backend/app/services/timelapse_processor.py

@@ -1,9 +1,11 @@
 """Timelapse video processing service using FFmpeg."""
 
 import asyncio
+import contextlib
 import json
 import logging
 import tempfile
+from collections.abc import Callable
 from pathlib import Path
 
 from backend.app.services.camera import get_ffmpeg_path
@@ -11,6 +13,26 @@ from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_s
 
 logger = logging.getLogger(__name__)
 
+# ffprobe and a single thumbnail frame take well under a second on a healthy
+# file; these bound a broken or unreadable one, which runs inside a request.
+PROBE_TIMEOUT_SECONDS = 60
+THUMBNAIL_TIMEOUT_SECONDS = 30
+
+
+async def _communicate(process: asyncio.subprocess.Process, timeout: float) -> tuple[bytes, bytes]:
+    """``process.communicate()`` that kills the process when it takes too long."""
+    try:
+        return await asyncio.wait_for(process.communicate(), timeout)
+    except (TimeoutError, asyncio.CancelledError):
+        _kill(process)
+        await process.wait()
+        raise
+
+
+def _kill(process: asyncio.subprocess.Process) -> None:
+    with contextlib.suppress(ProcessLookupError):
+        process.kill()
+
 
 class TimelapseProcessor:
     """Service for processing timelapse videos with FFmpeg."""
@@ -41,7 +63,10 @@ class TimelapseProcessor:
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
-        stdout, stderr = await process.communicate()
+        try:
+            stdout, stderr = await _communicate(process, PROBE_TIMEOUT_SECONDS)
+        except TimeoutError:
+            raise RuntimeError(f"ffprobe took longer than {PROBE_TIMEOUT_SECONDS} s") from None
 
         if process.returncode != 0:
             # Summarised once and used for both: the raise carried a second,
@@ -124,7 +149,11 @@ class TimelapseProcessor:
                     stdout=asyncio.subprocess.PIPE,
                     stderr=asyncio.subprocess.PIPE,
                 )
-                await process.communicate()
+                try:
+                    await _communicate(process, THUMBNAIL_TIMEOUT_SECONDS)
+                except TimeoutError:
+                    logger.warning("Timelapse thumbnail at %.1f s took too long; skipped", timestamp)
+                    continue
 
                 if output_path.exists():
                     thumbnails.append((timestamp, output_path.read_bytes()))
@@ -139,6 +168,7 @@ class TimelapseProcessor:
         speed: float = 1.0,
         audio_path: Path | None = None,
         audio_volume: float = 1.0,
+        on_progress: Callable[[float], None] | None = None,
     ) -> bool:
         """Process video with trim, speed, and optional audio overlay.
 
@@ -149,10 +179,28 @@ class TimelapseProcessor:
             speed: Speed multiplier (0.25 to 4.0)
             audio_path: Optional music file to overlay
             audio_volume: Volume for audio overlay (0.0 to 1.0)
+            on_progress: Called with the share done (0.0 to 1.0) as ffmpeg
+                reports its position
 
         Returns:
             True if processing succeeded, False otherwise
+
+        Cancelling the call stops ffmpeg and removes the partial output.
         """
+        # The output's length, for progress: what is kept of the input, at
+        # the new speed. An audio overlay with -shortest may end it earlier.
+        expected_seconds = None
+        if on_progress is not None:
+            end = trim_end if trim_end is not None and trim_end > trim_start else None
+            if end is None:
+                try:
+                    end = (await self.get_info())["duration"]
+                except Exception as e:
+                    # Progress is a nicety: encode without it.
+                    logger.warning("Timelapse length unknown, no progress: %s", e)
+            if end is not None:
+                expected_seconds = max(end - trim_start, 0) / speed
+
         # Build FFmpeg command
         cmd = [self.ffmpeg, "-y"]
 
@@ -219,6 +267,10 @@ class TimelapseProcessor:
                 "128k",
                 "-movflags",
                 "+faststart",  # Enable streaming
+                # Position reports on stdout, for progress.
+                "-progress",
+                "pipe:1",
+                "-nostats",
                 str(output_path),
             ]
         )
@@ -232,7 +284,17 @@ class TimelapseProcessor:
             stderr=asyncio.subprocess.PIPE,
         )
 
-        _, stderr = await process.communicate()
+        try:
+            _, stderr = await asyncio.gather(
+                self._read_progress(process, expected_seconds, on_progress),
+                process.stderr.read(),  # type: ignore[union-attr]
+            )
+            await process.wait()
+        except asyncio.CancelledError:
+            _kill(process)
+            await process.wait()
+            output_path.unlink(missing_ok=True)
+            raise
 
         if process.returncode != 0:
             logger.error("FFmpeg processing failed: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT)
@@ -240,6 +302,23 @@ class TimelapseProcessor:
 
         return output_path.exists()
 
+    @staticmethod
+    async def _read_progress(
+        process: asyncio.subprocess.Process,
+        expected_seconds: float | None,
+        on_progress: Callable[[float], None] | None,
+    ) -> None:
+        """Read ffmpeg's ``-progress`` lines, passing on the share done."""
+        assert process.stdout is not None
+        async for raw in process.stdout:
+            key, _, value = raw.decode(errors="replace").strip().partition("=")
+            if on_progress is None or not expected_seconds or key != "out_time_us":
+                continue
+            try:
+                on_progress(int(value) / 1_000_000 / expected_seconds)
+            except ValueError:
+                pass  # "N/A" before the first frame
+
     def _build_atempo_chain(self, speed: float) -> str:
         """Build atempo filter chain.
 

+ 0 - 82
backend/app/services/virtual_printer/certificate.py

@@ -73,67 +73,6 @@ class CertificateService:
         self.cert_path = cert_dir / "virtual_printer.crt"
         self.key_path = cert_dir / "virtual_printer.key"
 
-    def ensure_certificates(self) -> tuple[Path, Path]:
-        """Ensure certificates exist, generate if needed.
-
-        Returns:
-            Tuple of (cert_path, key_path)
-        """
-        if self.cert_path.exists() and self.key_path.exists():
-            if self._cert_matches_current_ca():
-                logger.debug("Using existing virtual printer certificates")
-                return self.cert_path, self.key_path
-            logger.warning(
-                "Existing per-VP certificate's issuer doesn't match the current CA "
-                "(likely a CA rotation since the cert was signed). Regenerating "
-                "to keep the slicer's imported CA in sync with the served chain."
-            )
-        return self.generate_certificates()
-
-    def _cert_matches_current_ca(self) -> bool:
-        """Check whether the on-disk per-VP cert was signed by the current CA.
-
-        Slicers that import the shared CA validate the per-VP cert against it.
-        If the CA has been rotated since the per-VP cert was signed, the chain
-        is broken even though both files exist on disk. ``ensure_certificates``
-        uses this to decide whether to regenerate.
-
-        Uses real signature verification — every CA generated before the
-        common name carried a per-install suffix is literally
-        "CN=Virtual Printer CA", so on those installs a DN-only compare would
-        incorrectly return True even after rotation.
-        """
-        try:
-            if not self.ca_cert_path.exists():
-                # No CA yet — let generate_certificates create one and the
-                # matching per-VP chain.
-                return False
-            cert_pem = self.cert_path.read_bytes()
-            cert = x509.load_pem_x509_certificate(cert_pem)
-            ca_pem = self.ca_cert_path.read_bytes()
-            ca_cert = x509.load_pem_x509_certificate(ca_pem)
-            from cryptography.exceptions import InvalidSignature
-            from cryptography.hazmat.primitives.asymmetric import padding
-
-            try:
-                ca_cert.public_key().verify(
-                    cert.signature,
-                    cert.tbs_certificate_bytes,
-                    padding.PKCS1v15(),
-                    cert.signature_hash_algorithm,
-                )
-                return True
-            except InvalidSignature:
-                return False
-        except (OSError, ValueError) as e:
-            logger.debug("CA-match probe failed for %s: %s", self.cert_path, e)
-            return False
-        except Exception as e:
-            # Any unexpected exception during verification → treat as mismatch
-            # and regenerate. Safer than reusing a cert we can't validate.
-            logger.debug("CA-match verification failed for %s: %s", self.cert_path, e)
-            return False
-
     def _load_existing_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate] | None:
         """Try to load existing CA certificate and key.
 
@@ -458,24 +397,3 @@ class CertificateService:
             if path.exists():
                 path.unlink()
         logger.info("Deleted printer certificate (CA preserved)")
-
-    def delete_certificates(self, include_ca: bool = False) -> None:
-        """Delete existing certificates.
-
-        Args:
-            include_ca: If True, also delete CA certificate and key.
-                       If False (default), only delete printer certificate.
-        """
-        # Always delete printer certificate
-        for path in [self.cert_path, self.key_path]:
-            if path.exists():
-                path.unlink()
-
-        # Only delete CA if explicitly requested
-        if include_ca:
-            for path in [self.ca_cert_path, self.ca_key_path]:
-                if path.exists():
-                    path.unlink()
-            logger.info("Deleted all certificates including CA")
-        else:
-            logger.info("Deleted printer certificate (CA preserved)")

+ 11 - 25
backend/app/services/virtual_printer/manager.py

@@ -257,7 +257,6 @@ class VirtualPrinterInstance:
         gcode_injection: bool = False,
         bind_ip: str = "",
         remote_interface_ip: str = "",
-        tailscale_disabled: bool = True,
         base_dir: Path,
         session_factory: Callable | None = None,
         printer_manager: "PrinterManager | None" = None,
@@ -281,7 +280,6 @@ class VirtualPrinterInstance:
         self.gcode_injection = gcode_injection
         self.bind_ip = bind_ip
         self.remote_interface_ip = remote_interface_ip
-        self.tailscale_disabled = tailscale_disabled
         self._session_factory = session_factory
         self._printer_manager = printer_manager
 
@@ -302,7 +300,8 @@ class VirtualPrinterInstance:
             shared_ca_dir=shared_ca_dir,
         )
 
-        # Pending files for MQTT correlation
+        # Uploads received and not yet archived or queued; only counted, for
+        # the status's pending_files.
         self._pending_files: dict[str, Path] = {}
 
         # Slicer-side print options captured from the MQTT `project_file`
@@ -762,6 +761,7 @@ class VirtualPrinterInstance:
         """Archive file immediately."""
         if not self._session_factory:
             logger.error("Cannot archive: no database session factory configured")
+            self._pending_files.pop(file_path.name, None)
             return
 
         if file_path.suffix.lower() != ".3mf":
@@ -801,11 +801,10 @@ class VirtualPrinterInstance:
         except Exception as e:
             logger.error("Error archiving file: %s", e)
         finally:
-            # Always release the in-flight marker and delete the temp file —
-            # previously the failure paths only logged and the next upload of
-            # the same name was silently rejected with "already uploading",
-            # the upload_dir filled up indefinitely, and the slicer received
-            # a clean 226 even though no archive existed (#audit-R2-1).
+            # Always drop it from the pending count and delete the temp file —
+            # previously the failure paths only logged, the upload_dir filled
+            # up indefinitely, and the slicer received a clean 226 even though
+            # no archive existed (#audit-R2-1).
             self._pending_files.pop(file_path.name, None)
             if archived:
                 try:
@@ -823,6 +822,7 @@ class VirtualPrinterInstance:
         """Queue file for user review."""
         if not self._session_factory:
             logger.error("Cannot queue: no database session factory configured")
+            self._pending_files.pop(file_path.name, None)
             return
 
         if file_path.suffix.lower() != ".3mf":
@@ -874,15 +874,15 @@ class VirtualPrinterInstance:
             except OSError:
                 pass
         finally:
-            # Always release the in-flight marker so concurrent uploads
-            # with the same filename aren't spuriously rejected after
-            # a queue failure.
+            # Always drop it from the pending count, also after a queue
+            # failure.
             self._pending_files.pop(file_path.name, None)
 
     async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
         """Archive file and add to print queue, assigned to target printer or model."""
         if not self._session_factory:
             logger.error("Cannot add to print queue: no database session factory configured")
+            self._pending_files.pop(file_path.name, None)
             return
 
         if file_path.suffix.lower() != ".3mf":
@@ -1906,7 +1906,6 @@ class VirtualPrinterManager:
                     gcode_injection=vp.gcode_injection,
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
-                    tailscale_disabled=vp.tailscale_disabled,
                     base_dir=self._base_dir,
                     session_factory=self._session_factory,
                 )
@@ -1928,7 +1927,6 @@ class VirtualPrinterManager:
                     gcode_injection=vp.gcode_injection,
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
-                    tailscale_disabled=vp.tailscale_disabled,
                     base_dir=self._base_dir,
                     session_factory=self._session_factory,
                     printer_manager=self._printer_manager,
@@ -1960,18 +1958,6 @@ class VirtualPrinterManager:
         """Get a running instance by ID."""
         return self._instances.get(vp_id)
 
-    def get_all_status(self) -> list[dict]:
-        """Get status for all running instances."""
-        return [
-            {
-                "id": inst.id,
-                "name": inst.name,
-                "mode": inst.mode,
-                **inst.get_status(),
-            }
-            for inst in self._instances.values()
-        ]
-
     # -- Legacy single-printer compat --
 
     def get_status(self) -> dict:

+ 0 - 5
backend/app/utils/filament_types.py

@@ -67,11 +67,6 @@ def canonical_filament_type(ftype: str | None) -> str:
     return _EQUIV_MAP.get(upper, upper)
 
 
-def filament_types_compatible(a: str | None, b: str | None) -> bool:
-    """Whether two filament types may stand in for one another."""
-    return canonical_filament_type(a) == canonical_filament_type(b)
-
-
 # ---------------------------------------------------------------------------
 # Printer-side material names
 # ---------------------------------------------------------------------------

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

@@ -1004,27 +1004,6 @@ def extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) ->
     return extract_plate_metadata_from_3mf(file_path, plate_id).print_time_seconds
 
 
-def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> str | None:
-    """Extract the build plate type (`curr_bed_type`) for a specific plate (#1281).
-
-    ``archive.bed_type`` is captured at ingest time but is one value per archive
-    (the first plate's `curr_bed_type` — see services/archive.py:235). For a
-    multi-plate 3MF where different plates target different beds (e.g. a 40-plate
-    file mixing PEI + Engineering), the archive-level value lies. When a queue
-    item or print modal targets a specific plate, this re-reads the 3MF and
-    returns that plate's actual bed type.
-
-    Args:
-        file_path: Path to the 3MF file
-        plate_id: Plate index to filter for; if None, returns the first plate's
-            ``curr_bed_type`` (matches the archive-level capture).
-
-    Returns:
-        Bed type string (e.g. "Textured PEI Plate"), or None if not found.
-    """
-    return extract_plate_metadata_from_3mf(file_path, plate_id).bed_type
-
-
 # Bed temperature is not one key in a BambuStudio project. Every plate type has
 # its own per-filament array, and the plate actually fitted is named separately
 # in ``curr_bed_type`` -- so reading a bed temperature means picking the array

+ 34 - 13
backend/tests/conftest.py

@@ -417,6 +417,24 @@ async def _finish_query_cancellations() -> None:
         await asyncio.wait(pending, timeout=5)
 
 
+async def _stop_background_tasks() -> None:
+    """Cancel the background tasks this test's code spawned, and wait for them.
+
+    A task still in a query when the loop closes gets its answer from
+    aiosqlite's worker thread into the closed loop ("Event loop is closed"),
+    reported as a thread exception in whatever test runs next on the worker.
+    Cancelled here, its session closes while the loop still runs.
+    """
+    from backend.app.core.tasks import _background_tasks
+
+    loop = asyncio.get_running_loop()
+    pending = [task for task in _background_tasks if not task.done() and task.get_loop() is loop]
+    for task in pending:
+        task.cancel()
+    if pending:
+        await asyncio.wait(pending, timeout=5)
+
+
 async def _release_app_engine() -> None:
     """Close the app engine's pooled connections before this test's loop ends.
 
@@ -427,18 +445,24 @@ async def _release_app_engine() -> None:
     PostgreSQL that leaves asyncpg's cleanup unawaited, reported as an
     unraisable "Connection._cancel" coroutine in some later test.
     """
+    # First: a task still holding a connection would return it to the pool
+    # after the dispose below has replaced that pool, and it is never closed.
+    await _stop_background_tasks()
     await _finish_query_cancellations()
     await _app_engine.dispose()
 
 
-if TEST_PG_URL is not None:
+@pytest.fixture(autouse=True)
+async def _app_engine_released_after_each_test():
+    """Every test, not only those with test_engine: any of them can reach
+    the app engine through code that opens its own session.
 
-    @pytest.fixture(autouse=True)
-    async def _app_engine_released_after_each_test():
-        """Every test, not only those with test_engine: any of them can reach
-        the app engine through code that opens its own session."""
-        yield
-        await _release_app_engine()
+    On SQLite too: an aiosqlite connection belongs to the loop it was opened
+    in, so one kept in the pool answers the next test's query into a closed
+    loop ("Event loop is closed" from aiosqlite's worker thread).
+    """
+    yield
+    await _release_app_engine()
 
 
 @pytest.fixture
@@ -536,11 +560,9 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
             yield client
 
         # The app lifespan called init_db() which used the module-level engine
-        # (not the test engine), creating aiosqlite connections. Dispose those
-        # connections so their background threads finish before the event loop closes.
-        from backend.app.core.database import engine as real_engine
-
-        await real_engine.dispose()
+        # (not the test engine), creating aiosqlite connections. Close those,
+        # and stop background work still using them, before the loop closes.
+        await _release_app_engine()
 
     app.dependency_overrides.clear()
 
@@ -640,7 +662,6 @@ def mock_mqtt_smart_plug_service():
         # Create a mock smart_plug_service
         mock_service = MagicMock()
         mock_service.is_configured = MagicMock(return_value=True)
-        mock_service.has_broker_settings = MagicMock(return_value=True)
         mock_service.configure = AsyncMock(return_value=True)
         mock_service.subscribe = MagicMock()
         mock_service.unsubscribe = MagicMock()

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

@@ -1420,7 +1420,7 @@ class TestJobKeepsItsFinalStatus:
         )
         connection = await db_session.connection()
         # Ended before jobs stored it: written past the session hooks.
-        await connection.exec_driver_sql(f"UPDATE print_queue SET status = 'aborted' WHERE id = {int(item_id)}")
+        await connection.exec_driver_sql(f"UPDATE print_queue SET status = 'aborted' WHERE id = {int(item_id)}")  # nosec B608 -- int() id
         await db_session.commit()
 
         await _backfill_pipeline_job_final_status(await db_session.connection())

+ 6 - 6
backend/tests/integration/test_print_queue_api.py

@@ -62,7 +62,7 @@ class TestPrintQueueAPI:
             defaults = {
                 "filename": f"test_print_{counter}.3mf",
                 "print_name": f"Test Print {counter}",
-                "file_path": f"/tmp/test_print_{counter}.3mf",
+                "file_path": f"/tmp/test_print_{counter}.3mf",  # nosec B108 -- a stored path, never opened
                 "file_size": 1024,
                 "content_hash": f"testhash{counter:08d}",
                 "status": "completed",
@@ -1033,7 +1033,7 @@ class TestQueueStartEndpoint:
             defaults = {
                 "filename": f"test_print_{counter}.3mf",
                 "print_name": f"Test Print {counter}",
-                "file_path": f"/tmp/test_print_{counter}.3mf",
+                "file_path": f"/tmp/test_print_{counter}.3mf",  # nosec B108 -- a stored path, never opened
                 "file_size": 1024,
                 "content_hash": f"testhash{counter:08d}",
                 "status": "completed",
@@ -1381,7 +1381,7 @@ class TestQueueCancelEndpoint:
             defaults = {
                 "filename": "cancel_test.3mf",
                 "print_name": "Cancel Test Print",
-                "file_path": "/tmp/cancel_test.3mf",
+                "file_path": "/tmp/cancel_test.3mf",  # nosec B108 -- a stored path, never opened
                 "file_size": 1024,
                 "content_hash": "cancelhash001",
                 "status": "completed",
@@ -1700,7 +1700,7 @@ class TestBulkUpdateEndpoint:
             defaults = {
                 "filename": f"bulk_test_{counter}.3mf",
                 "print_name": f"Bulk Test Print {counter}",
-                "file_path": f"/tmp/bulk_test_{counter}.3mf",
+                "file_path": f"/tmp/bulk_test_{counter}.3mf",  # nosec B108 -- a stored path, never opened
                 "file_size": 1024,
                 "content_hash": f"bulkhash{counter:04d}",
                 "status": "completed",
@@ -1981,7 +1981,7 @@ class TestTargetLocationFeature:
             defaults = {
                 "filename": f"location_test_{counter}.3mf",
                 "print_name": f"Location Test Print {counter}",
-                "file_path": f"/tmp/location_test_{counter}.3mf",
+                "file_path": f"/tmp/location_test_{counter}.3mf",  # nosec B108 -- a stored path, never opened
                 "file_size": 1024,
                 "content_hash": f"lochash{counter:08d}",
                 "status": "completed",
@@ -2310,7 +2310,7 @@ class TestAbortedStatusNormalisation:
             defaults = {
                 "filename": f"abort_test_{counter}.3mf",
                 "print_name": f"Abort Test Print {counter}",
-                "file_path": f"/tmp/abort_test_{counter}.3mf",
+                "file_path": f"/tmp/abort_test_{counter}.3mf",  # nosec B108 -- a stored path, never opened
                 "file_size": 1024,
                 "content_hash": f"aborthash{counter:06d}",
                 "status": "completed",

+ 0 - 1
backend/tests/integration/test_settings_and_plug_secrets.py

@@ -28,7 +28,6 @@ SETTINGS_SECRETS = {
     "ha_token": "ha-tok",
     "prometheus_token": "prom-tok",
     "obico_ml_token": "obico-tok",
-    "virtual_printer_access_code": "12345678",
 }
 
 

+ 0 - 2
backend/tests/integration/test_settings_api_key_scrubbing.py

@@ -36,7 +36,6 @@ async def sensitive_settings(db_session):
         "mqtt_password",
         "ha_token",
         "prometheus_token",
-        "virtual_printer_access_code",
         "ldap_bind_password",
     ]
     for key in _credential_keys:
@@ -66,7 +65,6 @@ class TestSettingsScrubForApiKey:
         assert data["mqtt_password"] == ""
         assert data["ha_token"] == ""
         assert data["prometheus_token"] == ""
-        assert data["virtual_printer_access_code"] == ""
         assert data["ldap_bind_password"] == ""
 
     @pytest.mark.asyncio

+ 0 - 88
backend/tests/integration/test_spoolbuddy.py

@@ -1379,94 +1379,6 @@ class TestUpdateEndpoints:
         assert resp.status_code == 200
         assert resp.json()["status"] == "already_updating"
 
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_report_update_status_updating(self, async_client: AsyncClient, device_factory):
-        await device_factory(device_id="sb-upd-st", pending_command="update", update_status="pending")
-
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/devices/sb-upd-st/update-status",
-                json={"status": "updating", "message": "Fetching latest code..."},
-            )
-
-        assert resp.status_code == 200
-        mock_ws.broadcast.assert_called_once()
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["type"] == "spoolbuddy_update"
-        assert msg["update_status"] == "updating"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_report_update_status_complete_clears_command(self, async_client: AsyncClient, device_factory):
-        await device_factory(device_id="sb-upd-done", pending_command="update", update_status="updating")
-
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
-            mock_ws.broadcast = AsyncMock()
-            await async_client.post(
-                f"{API}/devices/sb-upd-done/update-status",
-                json={"status": "complete", "message": "Update complete, restarting..."},
-            )
-
-        # Heartbeat should have no pending command
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
-            mock_ws.broadcast = AsyncMock()
-            hb = await async_client.post(
-                f"{API}/devices/sb-upd-done/heartbeat",
-                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
-            )
-
-        assert hb.json()["pending_command"] is None
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_report_update_status_error(self, async_client: AsyncClient, device_factory):
-        await device_factory(device_id="sb-upd-err", pending_command="update", update_status="updating")
-
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/devices/sb-upd-err/update-status",
-                json={"status": "error", "message": "git fetch failed: network unreachable"},
-            )
-
-        assert resp.status_code == 200
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["update_status"] == "error"
-        assert "git fetch failed" in msg["update_message"]
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_report_update_status_unknown_device_404(self, async_client: AsyncClient):
-        resp = await async_client.post(
-            f"{API}/devices/ghost/update-status",
-            json={"status": "updating", "message": "test"},
-        )
-        assert resp.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_report_update_status_invalid_status_422(self, async_client: AsyncClient, device_factory):
-        """Arbitrary status strings must be rejected with 422 (H2: UpdateStatusRequest validation)."""
-        await device_factory(device_id="sb-upd-inv")
-        resp = await async_client.post(
-            f"{API}/devices/sb-upd-inv/update-status",
-            json={"status": "hacked", "message": "injected"},
-        )
-        assert resp.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_report_update_status_oversized_message_422(self, async_client: AsyncClient, device_factory):
-        """Message exceeding 255 chars must be rejected with 422 (H2/M4)."""
-        await device_factory(device_id="sb-upd-big")
-        resp = await async_client.post(
-            f"{API}/devices/sb-upd-big/update-status",
-            json={"status": "updating", "message": "x" * 256},
-        )
-        assert resp.status_code == 422
-
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_ssh_public_key_error_does_not_leak_exception_text(self, async_client: AsyncClient):

+ 0 - 2
backend/tests/integration/test_spoolman_api.py

@@ -1049,9 +1049,7 @@ class TestSpoolmanAPI:
             tray_weight=1000,
         )
         mock_spoolman_client.parse_ams_tray.return_value = mock_tray
-        mock_spoolman_client.convert_ams_slot_to_location = MagicMock(return_value="AMS A1")
         mock_spoolman_client.sync_ams_tray = AsyncMock(return_value={"id": 42})
-        mock_spoolman_client.clear_location_for_removed_spools = AsyncMock(return_value=0)
 
         with patch("backend.app.api.routes.spoolman.printer_manager") as pm_mock:
             mock_state = MagicMock()

+ 0 - 2
backend/tests/integration/test_system_support_vp_scope.py

@@ -166,10 +166,8 @@ async def test_the_legacy_virtual_printer_settings_keep_to_the_caller_printers(
     db_session.add(Settings(key="virtual_printer_target_printer_id", value=str(b_id)))
     await db_session.commit()
 
-    shown = await async_client.get("/api/v1/settings/virtual-printer", headers=member)
     changed = await async_client.put("/api/v1/settings/virtual-printer?archive_name_source=filename", headers=member)
 
-    assert shown.json()["target_printer_id"] is None
     assert changed.status_code == 404
 
     # The settings also reach the first virtual printer: with the stored target

+ 12 - 7
backend/tests/integration/test_telegram_reactions.py

@@ -92,6 +92,11 @@ def _track(poller: TelegramReactionPoller, *providers, token: str = BOT_TOKEN):
     poller._providers.setdefault(token, set()).update(p.id for p in providers)
 
 
+def _polling(poller: TelegramReactionPoller, provider_id: int) -> bool:
+    """Whether a running poll serves this provider."""
+    return any(provider_id in ids for token, ids in poller._providers.items() if token in poller._tasks)
+
+
 @pytest.fixture
 def telegram_provider(notification_provider_factory):
     async def _create(**kwargs):
@@ -740,9 +745,9 @@ class TestPoller:
         disabled = await telegram_provider(name="D", telegram_verdict_mode="both", enabled=False)
 
         await poller.sync()
-        assert poller.is_polling(reactions.id)
-        assert not poller.is_polling(buttons.id)
-        assert not poller.is_polling(disabled.id)
+        assert _polling(poller, reactions.id)
+        assert not _polling(poller, buttons.id)
+        assert not _polling(poller, disabled.id)
         assert set(poller._tasks) == {BOT_TOKEN}
 
         # The bot keeps its single poll while any provider wants it; the
@@ -752,8 +757,8 @@ class TestPoller:
         buttons.telegram_verdict_mode = "both"
         await db_session.commit()
         await poller.sync()
-        assert not poller.is_polling(reactions.id)
-        assert poller.is_polling(buttons.id)
+        assert not _polling(poller, reactions.id)
+        assert _polling(poller, buttons.id)
         assert poller._tasks[BOT_TOKEN] is first_task
         assert poller._providers[BOT_TOKEN] == {buttons.id}
 
@@ -764,7 +769,7 @@ class TestPoller:
         await poller.sync()
         assert first_task.cancelled() or first_task.cancelling()
         assert set(poller._tasks) == {"999:newtoken"}
-        assert poller.is_polling(buttons.id)
+        assert _polling(poller, buttons.id)
 
         await poller.aclose()
         assert poller._tasks == {}
@@ -786,7 +791,7 @@ class TestPoller:
 
         await poller.sync()
         assert len(poller._tasks) == 1
-        assert poller.is_polling(first.id) and poller.is_polling(second.id)
+        assert _polling(poller, first.id) and _polling(poller, second.id)
         poller.stop()
         _track(poller, first, second)
 

+ 239 - 0
backend/tests/integration/test_timelapse_editor_job.py

@@ -0,0 +1,239 @@
+"""The timelapse editor's re-encode runs as a job: started, polled, cancelled."""
+
+import asyncio
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services import timelapse_jobs
+from backend.app.services.timelapse_processor import TimelapseProcessor
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+@pytest.fixture(autouse=True)
+def _no_jobs():
+    timelapse_jobs._jobs.clear()
+    yield
+    timelapse_jobs._jobs.clear()
+
+
+@pytest.fixture
+def data_dir(tmp_path):
+    from backend.app.api.routes import archives
+
+    with (
+        patch.object(archives.settings, "base_dir", tmp_path),
+        patch("backend.app.services.timelapse_processor.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+    ):
+        yield tmp_path
+
+
+async def _archive(printer_factory, archive_factory, data_dir: Path):
+    printer = await printer_factory()
+    video = data_dir / "archive" / "1" / "timelapse.mp4"
+    video.parent.mkdir(parents=True)
+    video.write_bytes(b"original")
+    return await archive_factory(printer.id, timelapse_path="archive/1/timelapse.mp4")
+
+
+async def _settled(client: AsyncClient, archive_id: int) -> dict:
+    for _ in range(100):
+        body = (await client.get(f"/api/v1/archives/{archive_id}/timelapse/job")).json()
+        if body["status"] != "running":
+            return body
+        await asyncio.sleep(0.02)
+    raise AssertionError("job still running")
+
+
+def _encoder(gate: asyncio.Event | None = None, ok: bool = True):
+    async def process(self, output_path, on_progress=None, **kwargs):
+        on_progress(0.4)
+        if gate is not None:
+            await gate.wait()
+        output_path.write_bytes(b"edited")
+        return ok
+
+    return patch.object(TimelapseProcessor, "process", process)
+
+
+async def test_the_request_returns_before_the_encode_and_the_job_reports_how_it_went(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir
+):
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    gate = asyncio.Event()
+
+    with _encoder(gate):
+        started = await async_client.post(
+            f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace", "speed": "2"}
+        )
+        assert started.status_code == 202, started.text
+        assert started.json()["status"] == "running"
+        await asyncio.sleep(0.05)
+        running = (await async_client.get(f"/api/v1/archives/{archive.id}/timelapse/job")).json()
+        gate.set()
+        done = await _settled(async_client, archive.id)
+
+    assert (running["status"], running["progress"]) == ("running", 0.4)
+    assert done["status"] == "completed"
+    assert done["message"] == "Timelapse replaced successfully"
+    assert (data_dir / "archive/1/timelapse.mp4").read_bytes() == b"edited"
+
+
+async def test_a_second_start_while_one_runs_is_refused(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir
+):
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    gate = asyncio.Event()
+
+    with _encoder(gate):
+        first = await async_client.post(
+            f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"}
+        )
+        second = await async_client.post(
+            f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"}
+        )
+        gate.set()
+        await _settled(async_client, archive.id)
+
+    assert (first.status_code, second.status_code) == (202, 409)
+
+
+async def test_cancelling_keeps_the_original_and_removes_the_temporary_files(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir
+):
+    import tempfile
+
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    before = set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*"))
+
+    with _encoder(asyncio.Event()):
+        await async_client.post(
+            f"/api/v1/archives/{archive.id}/timelapse/process",
+            data={"save_mode": "replace"},
+            files={"audio": ("music.mp3", b"ID3", "audio/mpeg")},
+        )
+        cancelled = await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse/job")
+
+    assert cancelled.status_code == 200, cancelled.text
+    assert cancelled.json()["status"] == "cancelled"
+    assert (data_dir / "archive/1/timelapse.mp4").read_bytes() == b"original"
+    assert set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*")) == before
+    assert (await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse/job")).status_code == 404
+
+
+async def test_a_failed_encode_is_reported(async_client: AsyncClient, printer_factory, archive_factory, data_dir):
+    import tempfile
+
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    before = set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*"))
+
+    with _encoder(ok=False):
+        await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"})
+        done = await _settled(async_client, archive.id)
+
+    assert (done["status"], done["error"]) == ("failed", "Video processing failed")
+    assert (data_dir / "archive/1/timelapse.mp4").read_bytes() == b"original"
+    # The half-written output goes too.
+    assert set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*")) == before
+
+
+async def test_no_job_yet(async_client: AsyncClient, printer_factory, archive_factory, data_dir):
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+
+    assert (await async_client.get(f"/api/v1/archives/{archive.id}/timelapse/job")).status_code == 404
+    assert (await async_client.get("/api/v1/archives/999999/timelapse/job")).status_code == 404
+
+
+async def test_the_music_volume_reaches_the_encode(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir
+):
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    seen = {}
+
+    async def process(self, output_path, on_progress=None, **kwargs):
+        seen.update(kwargs)
+        output_path.write_bytes(b"edited")
+        return True
+
+    with patch.object(TimelapseProcessor, "process", process):
+        await async_client.post(
+            f"/api/v1/archives/{archive.id}/timelapse/process",
+            data={"save_mode": "replace", "audio_volume": "0.3"},
+            files={"audio": ("music.mp3", b"ID3", "audio/mpeg")},
+        )
+        await _settled(async_client, archive.id)
+
+    assert seen["audio_volume"] == 0.3
+
+
+@pytest.mark.parametrize("volume", ["-0.1", "1.5"])
+async def test_a_volume_out_of_range_is_refused(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir, volume
+):
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+
+    response = await async_client.post(
+        f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace", "audio_volume": volume}
+    )
+
+    assert response.status_code == 400
+    assert timelapse_jobs.get_job(archive.id) is None
+
+
+@pytest.mark.parametrize("change", ["removed", "replaced"])
+async def test_a_timelapse_changed_during_the_edit_is_not_overwritten(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir, db_session, change
+):
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    video = data_dir / "archive/1/timelapse.mp4"
+    gate = asyncio.Event()
+
+    with _encoder(gate):
+        await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"})
+        if change == "removed":
+            assert (await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse")).status_code == 200
+        else:
+            video.unlink()
+            video.write_bytes(b"a newer video")
+        gate.set()
+        done = await _settled(async_client, archive.id)
+
+    assert done["status"] == "failed"
+    assert "removed or replaced" in done["error"]
+    if change == "removed":
+        assert not video.exists()
+    else:
+        assert video.read_bytes() == b"a newer video"
+
+
+async def test_two_saves_racing_get_one_job_and_a_409(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir
+):
+    """The second passed the early check while the first one's audio upload was read."""
+    import tempfile
+
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    before = set(Path(tempfile.gettempdir()).glob(f"audio_{archive.id}_*"))
+    real_start = timelapse_jobs.start_job
+
+    async def other_request(job):
+        await asyncio.Event().wait()
+        return "", ""
+
+    def raced(archive_id, run):
+        real_start(archive_id, other_request)
+        return real_start(archive_id, run)
+
+    with _encoder(asyncio.Event()), patch.object(timelapse_jobs, "start_job", raced):
+        response = await async_client.post(
+            f"/api/v1/archives/{archive.id}/timelapse/process",
+            data={"save_mode": "replace"},
+            files={"audio": ("music.mp3", b"ID3", "audio/mpeg")},
+        )
+    await timelapse_jobs.cancel_all()
+
+    assert response.status_code == 409
+    assert set(Path(tempfile.gettempdir()).glob(f"audio_{archive.id}_*")) == before

+ 23 - 5
backend/tests/integration/test_virtual_printer_api.py

@@ -24,10 +24,7 @@ class TestVirtualPrinterSettingsAPI:
 
         assert response.status_code == 200
         result = response.json()
-        assert "enabled" in result
-        assert "access_code_set" in result
-        assert "mode" in result
-        assert "status" in result
+        assert set(result) == {"tailscale_disabled", "archive_name_source", "status"}
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -103,6 +100,27 @@ class TestVirtualPrinterSettingsAPI:
         assert response.status_code == 200
         assert response.json()["archive_name_source"] == "filename"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_tailscale_switch_reaches_the_first_virtual_printer(self, async_client: AsyncClient, db_session):
+        """It only changes what that printer's card shows; nothing is restarted."""
+        from sqlalchemy import select
+
+        from backend.app.models.virtual_printer import VirtualPrinter
+
+        with patch("backend.app.services.virtual_printer.virtual_printer_manager.sync_from_db", AsyncMock()) as sync:
+            created = await async_client.post("/api/v1/virtual-printers", json={"name": "first", "mode": "archive"})
+            assert created.status_code in (200, 201), created.text
+            sync.reset_mock()
+            response = await async_client.put("/api/v1/settings/virtual-printer?tailscale_disabled=false")
+
+        assert response.status_code == 200, response.text
+        assert response.json()["tailscale_disabled"] is False
+        db_session.expire_all()
+        first = (await db_session.execute(select(VirtualPrinter))).scalar_one()
+        assert first.tailscale_disabled is False
+        sync.assert_not_called()
+
 
 class TestPendingUploadsAPI:
     """Integration tests for /api/v1/pending-uploads/ endpoints."""
@@ -118,7 +136,7 @@ class TestPendingUploadsAPI:
 
             upload = PendingUpload(
                 filename=filename,
-                file_path=f"/tmp/{filename}",
+                file_path=f"/tmp/{filename}",  # nosec B108 -- a stored path, never opened
                 file_size=1024,
                 source_ip="192.168.1.100",
                 status="pending",

+ 5 - 127
backend/tests/unit/services/test_bambu_ftp.py

@@ -7,7 +7,7 @@ Tests against a real mock implicit FTPS server, covering:
 - Upload (chunked transfer, progress, error codes)
 - Delete
 - File size
-- Storage info (AVBL, directory scan, diagnose_storage)
+- Storage info (AVBL, directory scan)
 - Model-specific behavior (X1C prot_p, A1 prot_c fallback)
 - Async wrappers
 - Failure injection scenarios (regressions for 0.1.8 bugs)
@@ -489,12 +489,14 @@ class TestUpload:
         errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
         assert bool(errors) is logged_error
 
-    def test_the_read_back_reads_the_size_on_its_own_connection(self, ftp_client_factory, ftp_server):
+    def test_the_read_back_reads_the_size_on_its_own_connection(self, ftp_client_factory, ftp_server, tmp_path):
         import backend.app.services.bambu_ftp as ftp_mod
 
+        local = tmp_path / "r.bin"
+        local.write_bytes(b"x" * 300)
         client = ftp_client_factory()
         client.connect()
-        assert client.upload_bytes(b"x" * 300, "/cache/r.bin") is True
+        assert client.upload_file(local, "/cache/r.bin") is True
 
         assert ftp_mod._size_on_new_connection(client, "/cache/r.bin") == 300
         client.disconnect()
@@ -512,28 +514,6 @@ class TestUpload:
 
         assert not ftp_mod.BambuFTPClient.handshake_blocked("127.0.0.1")
 
-    def test_upload_bytes_without_a_226_is_checked_too(self, ftp_client_factory, ftp_server):
-        import backend.app.services.bambu_ftp as ftp_mod
-
-        client = ftp_client_factory()
-        client.connect()
-
-        def never_answers():
-            raise TimeoutError("timed out")
-
-        client._ftp.voidresp = never_answers
-        checked: list[str] = []
-
-        def fake_size(c, remote_path):
-            checked.append(remote_path)
-            return 3
-
-        with patch.object(ftp_mod, "_size_on_new_connection", fake_size):
-            assert client.upload_bytes(b"abc", "/cache/b.bin") is True
-        client.disconnect()
-
-        assert checked == ["/cache/b.bin"]
-
     def test_upload_426_with_intact_file_proceeds(self, ftp_client_factory, ftp_server, tmp_path):
         """Some P2S firmware revisions return 426 on voidresp() even when the
         file landed fully (TLS data-channel close races the 226). #1417
@@ -676,73 +656,6 @@ class TestUpload:
         assert result is False
         client.disconnect()
 
-    def test_upload_bytes_426_with_intact_file_proceeds(self, ftp_client_factory, ftp_server):
-        """upload_bytes() mirrors the same SIZE-verify logic as upload_file."""
-        import ftplib  # nosec B402 — tests need the real ftplib to construct mock 426 responses
-
-        client = ftp_client_factory()
-        client.connect()
-        data = b"x" * 1024
-
-        def raise_426():
-            raise ftplib.error_temp("426 Failure reading network stream.")
-
-        def fake_size(_path):
-            return 1024  # printer-side file matches expected size
-
-        client._ftp.voidresp = raise_426
-        client._ftp.size = fake_size
-
-        result = client.upload_bytes(data, "/cache/bytes.bin")
-        assert result is True
-        client.disconnect()
-
-    def test_upload_bytes_426_with_truncated_file_returns_false(self, ftp_client_factory, ftp_server):
-        """The truncated branch for upload_bytes()."""
-        import ftplib  # nosec B402 — tests need the real ftplib to construct mock 426 responses
-
-        client = ftp_client_factory()
-        client.connect()
-        data = b"x" * 1024
-
-        def raise_426():
-            raise ftplib.error_temp("426 Failure reading network stream.")
-
-        def fake_size(_path):
-            return 100
-
-        client._ftp.voidresp = raise_426
-        client._ftp.size = fake_size
-
-        result = client.upload_bytes(data, "/cache/bytes.bin")
-        assert result is False
-        client.disconnect()
-
-    def test_upload_bytes_success(self, ftp_client_factory, ftp_server):
-        """upload_bytes() writes data to server."""
-        data = b"Bytes upload content"
-        client = ftp_client_factory()
-        client.connect()
-        result = client.upload_bytes(data, "/cache/bytes.bin")
-        assert result is True
-        client.disconnect()
-        # Verify via fresh connection
-        time.sleep(_UPLOAD_FLUSH_DELAY)
-        client2 = ftp_client_factory()
-        client2.connect()
-        downloaded = client2.download_file("/cache/bytes.bin")
-        assert downloaded == data
-        client2.disconnect()
-
-    def test_upload_bytes_failure(self, ftp_client_factory, ftp_server):
-        """upload_bytes() returns False on STOR failure."""
-        ftp_server.inject_failure("STOR", 553, "No space.")
-        client = ftp_client_factory()
-        client.connect()
-        result = client.upload_bytes(b"data", "/cache/fail.bin")
-        assert result is False
-        client.disconnect()
-
     def test_upload_large_chunked(self, ftp_client_factory, ftp_server, tmp_path):
         """Large file upload in chunks completes without error.
 
@@ -868,25 +781,6 @@ class TestStorageInfo:
         client = BambuFTPClient("127.0.0.1", "12345678")
         assert client.get_storage_info() is None
 
-    def test_diagnose_storage_success(self, ftp_client_factory, ftp_server):
-        """diagnose_storage() returns connected=True with working diagnostics."""
-        client = ftp_client_factory()
-        client.connect()
-        diag = client.diagnose_storage()
-        assert diag["connected"] is True
-        assert diag["can_list_root"] is True
-        assert diag["can_list_cache"] is True
-        assert diag["pwd"] is not None
-        assert diag["storage_info"] is not None
-        client.disconnect()
-
-    def test_diagnose_storage_not_connected(self):
-        """diagnose_storage() reports not connected."""
-        client = BambuFTPClient("127.0.0.1", "12345678")
-        diag = client.diagnose_storage()
-        assert diag["connected"] is False
-        assert "FTP not connected" in diag["errors"]
-
 
 # ---------------------------------------------------------------------------
 # TestModelSpecificBehavior
@@ -955,7 +849,6 @@ class TestModelSpecificBehavior:
     def test_unknown_model_defaults_prot_p(self, ftp_client_factory):
         """Unknown model defaults to prot_p."""
         client = ftp_client_factory(printer_model="FuturePrinter3000")
-        assert client._is_a1_model() is False
         assert client._should_use_prot_c() is False
         assert client.connect() is True
         client.disconnect()
@@ -1427,21 +1320,6 @@ class TestFailureScenarios:
         assert result is False
         client.disconnect()
 
-    def test_diagnose_storage_cwd_failure_doesnt_propagate(self, ftp_client_factory, ftp_server):
-        """diagnose_storage CWD failure doesn't crash the whole operation.
-
-        Regression: diagnose_storage() was called in the upload path and
-        a CWD failure would propagate and crash the upload.
-        """
-        ftp_server.inject_failure("CWD", 550, "No such directory.", count=2)
-        client = ftp_client_factory()
-        client.connect()
-        diag = client.diagnose_storage()
-        # Should still return results (with errors noted)
-        assert diag["connected"] is True
-        assert len(diag["errors"]) > 0
-        client.disconnect()
-
     def test_failure_injection_count_decrements(self, ftp_client_factory, ftp_server):
         """Failure injection with count decrements and eventually succeeds."""
         ftp_server.add_file("cache/retry.bin", b"data after retry")

+ 3 - 3
backend/tests/unit/services/test_ftp_download_deadlines.py

@@ -136,7 +136,7 @@ def _client_with(error: Exception) -> bambu_ftp.BambuFTPClient:
 
 
 def test_a_missing_path_is_told_apart_when_asked():
-    import ftplib
+    import ftplib  # nosec B402 -- tests construct real ftplib error types
 
     missing = ftplib.error_perm("550 No such file")
     with pytest.raises(bambu_ftp.FileNotOnPrinterError):
@@ -148,7 +148,7 @@ def test_a_missing_path_is_told_apart_when_asked():
 
 
 def test_other_ftp_errors_are_failures_not_missing():
-    import ftplib
+    import ftplib  # nosec B402 -- tests construct real ftplib error types
 
     assert _client_with(ftplib.error_perm("530 Not logged in")).download_file("/x", report_missing=True) is None
     assert _client_with(OSError("reset")).download_file("/x", report_missing=True) is None
@@ -219,7 +219,7 @@ async def test_a_printer_file_past_its_deadline_is_a_timeout(monkeypatch, tmp_pa
 def test_a_size_refusal_is_left_to_retr():
     """A 550 to SIZE (no such command, a file still being written) does not
     make a file missing that RETR then serves."""
-    import ftplib
+    import ftplib  # nosec B402 -- tests construct real ftplib error types
     from unittest.mock import MagicMock
 
     client = bambu_ftp.BambuFTPClient("10.0.0.5", "code")

+ 14 - 47
backend/tests/unit/services/test_layer_timelapse.py

@@ -15,7 +15,7 @@ import pytest
 class TestTimelapseSessionManagement:
     """Tests for timelapse session lifecycle."""
 
-    def test_start_session_creates_new_session(self):
+    def test_start_session_creates_new_session(self, tmp_path):
         """Verify start_session creates and registers a new session."""
         from backend.app.services.layer_timelapse import (
             _active_sessions,
@@ -28,7 +28,7 @@ class TestTimelapseSessionManagement:
         _active_sessions.clear()
 
         with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
-            mock_settings.base_dir = Path("/tmp/test_bambuddy")
+            mock_settings.base_dir = tmp_path
 
             session = start_session(
                 printer_id=1,
@@ -52,7 +52,7 @@ class TestTimelapseSessionManagement:
             # Cleanup
             cancel_session(1)
 
-    def test_start_session_cancels_existing(self):
+    def test_start_session_cancels_existing(self, tmp_path):
         """Verify starting a new session cancels any existing session."""
         from backend.app.services.layer_timelapse import (
             _active_sessions,
@@ -64,7 +64,7 @@ class TestTimelapseSessionManagement:
         _active_sessions.clear()
 
         with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
-            mock_settings.base_dir = Path("/tmp/test_bambuddy")
+            mock_settings.base_dir = tmp_path
 
             # Start first session
             session1 = start_session(1, 100, "http://cam1/", "mjpeg")
@@ -96,7 +96,7 @@ class TestTimelapseSessionManagement:
         result = get_session(999)
         assert result is None
 
-    def test_cancel_session_removes_and_cleans_up(self):
+    def test_cancel_session_removes_and_cleans_up(self, tmp_path):
         """Verify cancel_session removes session and cleans up."""
         from backend.app.services.layer_timelapse import (
             _active_sessions,
@@ -108,7 +108,7 @@ class TestTimelapseSessionManagement:
         _active_sessions.clear()
 
         with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
-            mock_settings.base_dir = Path("/tmp/test_bambuddy")
+            mock_settings.base_dir = tmp_path
 
             session = start_session(1, 100, "http://cam/", "mjpeg")
 
@@ -135,14 +135,14 @@ class TestTimelapseSessionManagement:
 class TestTimelapseSession:
     """Tests for TimelapseSession class."""
 
-    def test_session_id_format(self):
+    def test_session_id_format(self, tmp_path):
         """Verify session ID follows expected datetime format."""
         from backend.app.services.layer_timelapse import TimelapseSession, _active_sessions
 
         _active_sessions.clear()
 
         with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
-            mock_settings.base_dir = Path("/tmp/test_bambuddy")
+            mock_settings.base_dir = tmp_path
 
             session = TimelapseSession(
                 printer_id=1,
@@ -184,12 +184,12 @@ class TestLayerChangeLogic:
     """Tests for layer change capture logic."""
 
     @pytest.mark.asyncio
-    async def test_capture_layer_only_on_increase(self):
+    async def test_capture_layer_only_on_increase(self, tmp_path):
         """Verify frames are only captured when layer increases."""
         from backend.app.services.layer_timelapse import TimelapseSession
 
         with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
-            mock_settings.base_dir = Path("/tmp/test")
+            mock_settings.base_dir = tmp_path
 
             with patch.object(Path, "mkdir"):
                 session = TimelapseSession(1, 100, "http://test/", "mjpeg")
@@ -224,12 +224,12 @@ class TestLayerChangeLogic:
                         assert session.frame_count == 2
 
     @pytest.mark.asyncio
-    async def test_capture_layer_handles_failed_capture(self):
+    async def test_capture_layer_handles_failed_capture(self, tmp_path):
         """Verify failed capture returns False but updates layer."""
         from backend.app.services.layer_timelapse import TimelapseSession
 
         with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
-            mock_settings.base_dir = Path("/tmp/test")
+            mock_settings.base_dir = tmp_path
 
             with patch.object(Path, "mkdir"):
                 session = TimelapseSession(1, 100, "http://test/", "mjpeg")
@@ -344,7 +344,7 @@ class TestOnLayerChange:
     """Tests for the on_layer_change callback."""
 
     @pytest.mark.asyncio
-    async def test_on_layer_change_captures_when_session_exists(self):
+    async def test_on_layer_change_captures_when_session_exists(self, tmp_path):
         """Verify on_layer_change triggers capture when session exists."""
         from backend.app.services.layer_timelapse import (
             _active_sessions,
@@ -356,7 +356,7 @@ class TestOnLayerChange:
         _active_sessions.clear()
 
         with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
-            mock_settings.base_dir = Path("/tmp/test")
+            mock_settings.base_dir = tmp_path
 
             with patch.object(Path, "mkdir"):
                 session = start_session(1, 100, "http://test/", "mjpeg")
@@ -381,39 +381,6 @@ class TestOnLayerChange:
         await on_layer_change(999, 10)
 
 
-class TestGetActiveSessions:
-    """Tests for get_active_sessions."""
-
-    def test_get_active_sessions_returns_copy(self):
-        """Verify get_active_sessions returns a copy, not the original dict."""
-        from backend.app.services.layer_timelapse import (
-            _active_sessions,
-            cancel_session,
-            get_active_sessions,
-            start_session,
-        )
-
-        _active_sessions.clear()
-
-        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
-            mock_settings.base_dir = Path("/tmp/test")
-
-            with patch.object(Path, "mkdir"):
-                start_session(1, 100, "http://test/", "mjpeg")
-
-                sessions = get_active_sessions()
-
-                # Should be a copy
-                assert sessions is not _active_sessions
-                assert 1 in sessions
-
-                # Modifying copy shouldn't affect original
-                sessions.clear()
-                assert 1 in _active_sessions
-
-                cancel_session(1)
-
-
 class TestCleanupOrphanedTimelapseSessions:
     """_active_sessions is in-memory only, so a process restart mid-print
     loses track of an active session without ever cleaning up its frames

+ 4 - 5
backend/tests/unit/services/test_notify_widget_client.py

@@ -30,7 +30,6 @@ async def test_widgets_create_explicitly_then_use_the_exact_saved_handle():
         client = NotifyClient(http)
         assert await client.create_widget("ABC12345", "a+b&c", widget["content"]) == widget
         assert await client.update_widget("WG123456", "a+b&c", {"value": "Idle", "progress": None}) == widget
-        assert await client.get_widget("WG123456", "a+b&c") == widget
         assert await client.list_widgets("ABC12345", "a+b&c") == {"widgets": [widget]}
         assert (await client.delete_widget("WG123456", "a+b&c"))["deleted"] is True
 
@@ -38,12 +37,12 @@ async def test_widgets_create_explicitly_then_use_the_exact_saved_handle():
     assert requests[0].url.params["new"] == "1"
     assert requests[0].headers["content-type"] == "application/json"
     assert json.loads(requests[0].content) == widget["content"]
-    assert requests[1].url.path == requests[2].url.path == requests[4].url.path == "/widgets/WG123456"
+    assert requests[1].url.path == requests[3].url.path == "/widgets/WG123456"
     assert json.loads(requests[1].content) == {"value": "Idle", "progress": None}
     for request in requests[1:]:
         assert dict(request.url.params) == {"token": "a+b&c"}
-    assert [r.method for r in requests] == ["POST", "POST", "GET", "GET", "DELETE"]
-    assert not requests[2].content and not requests[3].content and not requests[4].content
+    assert [r.method for r in requests] == ["POST", "POST", "GET", "DELETE"]
+    assert not requests[2].content and not requests[3].content
 
 
 async def test_pasted_credentials_are_normalized_for_activity_and_widget_requests():
@@ -101,7 +100,7 @@ async def test_legacy_la_prefixed_device_can_start_after_identity_verification()
     assert requests[1].url.params["new"] == "1"
 
 
-@pytest.mark.parametrize("method", ["update_widget", "get_widget", "delete_widget"])
+@pytest.mark.parametrize("method", ["update_widget", "delete_widget"])
 @pytest.mark.parametrize("widget_id", ["ABC12345", "WGbad123", "WG12345", "WG1234567", "../WG123456", None])
 async def test_widget_operations_reject_device_dialect_and_invalid_handles(method, widget_id):
     http = AsyncMock(spec=httpx.AsyncClient)

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

@@ -528,51 +528,6 @@ class TestPrinterManager:
         result = manager.stop_print(999)
         assert result is False
 
-    # ========================================================================
-    # Tests for wait_for_cooldown
-    # ========================================================================
-
-    @pytest.mark.asyncio
-    async def test_wait_for_cooldown_returns_true_when_cool(self, manager, mock_client):
-        """Verify wait_for_cooldown returns True when printer is cool."""
-        mock_client.state.connected = True
-        mock_client.state.temperatures = {"nozzle": 40, "bed": 30}
-        mock_client.check_staleness.return_value = True
-        manager._clients[1] = mock_client
-
-        result = await manager.wait_for_cooldown(1, target_temp=50)
-
-        assert result is True
-
-    @pytest.mark.asyncio
-    async def test_wait_for_cooldown_returns_false_on_disconnect(self, manager, mock_client):
-        """Verify wait_for_cooldown returns False when printer disconnects."""
-        mock_client.state.connected = False
-        mock_client.check_staleness.return_value = False
-        manager._clients[1] = mock_client
-
-        result = await manager.wait_for_cooldown(1, target_temp=50, timeout=1)
-
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_wait_for_cooldown_returns_false_for_unknown(self, manager):
-        """Verify wait_for_cooldown returns False for unknown printer."""
-        result = await manager.wait_for_cooldown(999, target_temp=50, timeout=1)
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_wait_for_cooldown_checks_both_nozzles(self, manager, mock_client):
-        """Verify wait_for_cooldown checks both nozzles for dual extruders."""
-        mock_client.state.connected = True
-        mock_client.state.temperatures = {"nozzle": 40, "nozzle_2": 45, "bed": 30}
-        mock_client.check_staleness.return_value = True
-        manager._clients[1] = mock_client
-
-        result = await manager.wait_for_cooldown(1, target_temp=50)
-
-        assert result is True
-
     # ========================================================================
     # Tests for is_print_active (#1890)
     # ========================================================================

+ 0 - 23
backend/tests/unit/services/test_slicer_api.py

@@ -571,29 +571,6 @@ class TestSliceOutputValidation:
             )
 
 
-class TestHealth:
-    @pytest.mark.asyncio
-    async def test_health_returns_body(self):
-        def handler(request: httpx.Request) -> httpx.Response:
-            return httpx.Response(
-                status_code=200,
-                json={"status": "healthy", "checks": {"orcaslicer": {"available": True}}},
-            )
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        body = await service.health()
-        assert body["status"] == "healthy"
-
-    @pytest.mark.asyncio
-    async def test_health_unreachable_raises(self):
-        def handler(request: httpx.Request) -> httpx.Response:
-            raise httpx.ConnectError("no route")
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        with pytest.raises(SlicerApiUnavailableError):
-            await service.health()
-
-
 class TestSliceWithProfilesProgress:
     """Live-progress wiring for slice_with_profiles.
 

+ 0 - 15
backend/tests/unit/services/test_smart_plug_manager.py

@@ -367,21 +367,6 @@ class TestSmartPlugManager:
         with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock):
             manager._cancel_pending_off(999)  # Non-existent plug ID
 
-    @pytest.mark.asyncio
-    async def test_cancel_all_pending(self, manager, mock_plug):
-        """Verify all pending tasks can be cancelled."""
-        mock_task1 = MagicMock()
-        mock_task2 = MagicMock()
-        manager._pending_off[1] = mock_task1
-        manager._pending_off[2] = mock_task2
-
-        with patch("asyncio.create_task"):
-            manager.cancel_all_pending()
-
-        assert len(manager._pending_off) == 0
-        mock_task1.cancel.assert_called_once()
-        mock_task2.cancel.assert_called_once()
-
     # ========================================================================
     # Tests for scheduler
     # ========================================================================

+ 0 - 47
backend/tests/unit/services/test_spoolman_service.py

@@ -478,22 +478,6 @@ class TestSpoolmanClient:
             assert result["id"] == 1
             mock_get.assert_called_once()  # Should call get_spools
 
-    @pytest.mark.asyncio
-    async def test_find_spools_by_location_prefix_with_cached_spools(self, client):
-        """Verify find_spools_by_location_prefix uses cached spools when provided."""
-        cached = [
-            {"id": 1, "location": "Printer1 - AMS A1"},
-            {"id": 2, "location": "Printer2 - AMS A1"},
-            {"id": 3, "location": "Printer1 - AMS A2"},
-        ]
-
-        with patch.object(client, "get_spools", AsyncMock()) as mock_get:
-            result = await client.find_spools_by_location_prefix("Printer1 - ", cached_spools=cached)
-            assert len(result) == 2
-            assert result[0]["id"] == 1
-            assert result[1]["id"] == 3
-            mock_get.assert_not_called()  # Should NOT call get_spools
-
     @pytest.mark.asyncio
     async def test_sync_ams_tray_with_cached_spools(self, client, sample_tray, existing_spool):
         """Verify sync_ams_tray passes cached_spools to find_spool_by_tag."""
@@ -506,37 +490,6 @@ class TestSpoolmanClient:
             await client.sync_ams_tray(sample_tray, "TestPrinter", _NoCatalog(), cached_spools=cached)
             mock_get.assert_not_called()  # Should NOT call get_spools
 
-    @pytest.mark.asyncio
-    async def test_clear_location_for_removed_spools_with_cached_spools(self, client):
-        """Verify clear_location_for_removed_spools uses cached spools."""
-        cached = [
-            {"id": 1, "location": "Printer1 - AMS A1", "extra": {"tag": '"A1B2C3D4E5F60718293A4B5C6D7E8F90"'}},
-            {"id": 2, "location": "Printer1 - AMS A2", "extra": {"tag": '"B1C2D3E4F5061728394A5B6C7D8E9F01"'}},
-            {"id": 3, "location": "Printer1 - AMS A3", "extra": {"tag": '"C1D2E3F40516273849A5B6C7D8E9F012"'}},
-        ]
-        # Tag 3 was cleared, so only tags 1 and 2 are current
-        current_tags = {
-            "A1B2C3D4E5F60718293A4B5C6D7E8F90",
-            "B1C2D3E4F5061728394A5B6C7D8E9F01",
-        }
-
-        with (
-            patch.object(client, "get_spools", AsyncMock()) as mock_get,
-            patch.object(client, "update_spool", AsyncMock(return_value={"id": 3})) as mock_update,
-        ):
-            cleared = await client.clear_location_for_removed_spools("Printer1", current_tags, cached_spools=cached)
-            assert cleared == 1
-            mock_get.assert_not_called()  # Should NOT call get_spools
-            mock_update.assert_called_once()
-            # Verify it cleared TAG3 (not in current_tags)
-            call_kwargs = mock_update.call_args.kwargs
-            assert call_kwargs["spool_id"] == 3
-            assert call_kwargs.get("clear_location") is True
-
-    # ========================================================================
-    # Tests for retry logic in get_spools
-    # ========================================================================
-
     @pytest.mark.asyncio
     async def test_get_spools_succeeds_on_first_attempt(self, client):
         """Verify get_spools succeeds immediately when no errors occur."""

+ 197 - 0
backend/tests/unit/services/test_timelapse_jobs.py

@@ -0,0 +1,197 @@
+"""Timelapse editor re-encodes run as background jobs that report progress and can be cancelled."""
+
+import asyncio
+import shutil
+import stat
+import sys
+from pathlib import Path
+
+import pytest
+
+from backend.app.services import timelapse_jobs
+from backend.app.services.timelapse_processor import TimelapseProcessor, _communicate
+
+
+@pytest.fixture(autouse=True)
+def _no_jobs():
+    timelapse_jobs._jobs.clear()
+    yield
+    timelapse_jobs._jobs.clear()
+
+
+async def _finished(job: timelapse_jobs.TimelapseJob) -> None:
+    assert job.task is not None
+    await asyncio.wait_for(asyncio.shield(job.task), 5)
+
+
+class TestJobs:
+    @pytest.mark.asyncio
+    async def test_a_finished_job_keeps_its_result(self):
+        async def run(job):
+            job.set_progress(0.5)
+            return "archive/1/out.mp4", "Saved as out.mp4"
+
+        job = timelapse_jobs.start_job(1, run)
+        assert job.status == timelapse_jobs.RUNNING
+        await _finished(job)
+
+        assert timelapse_jobs.get_job(1) is job
+        assert (job.status, job.progress, job.output_path, job.message) == (
+            timelapse_jobs.COMPLETED,
+            1.0,
+            "archive/1/out.mp4",
+            "Saved as out.mp4",
+        )
+
+    @pytest.mark.asyncio
+    async def test_a_failed_job_says_why(self):
+        async def run(job):
+            raise RuntimeError("Video processing failed")
+
+        job = timelapse_jobs.start_job(1, run)
+        await _finished(job)
+
+        assert (job.status, job.error) == (timelapse_jobs.FAILED, "Video processing failed")
+
+    @pytest.mark.asyncio
+    async def test_one_job_per_archive_at_a_time(self):
+        release = asyncio.Event()
+
+        async def run(job):
+            await release.wait()
+            return "a", "b"
+
+        job = timelapse_jobs.start_job(1, run)
+        with pytest.raises(timelapse_jobs.JobAlreadyRunning):
+            timelapse_jobs.start_job(1, run)
+        other = timelapse_jobs.start_job(2, run)
+
+        release.set()
+        await _finished(job)
+        await _finished(other)
+        again = timelapse_jobs.start_job(1, run)
+        await _finished(again)
+        assert again.status == timelapse_jobs.COMPLETED
+
+    @pytest.mark.asyncio
+    async def test_cancelling_stops_the_job(self):
+        started = asyncio.Event()
+        stopped = []
+
+        async def run(job):
+            started.set()
+            try:
+                await asyncio.sleep(30)
+            except asyncio.CancelledError:
+                stopped.append(True)
+                raise
+            return "a", "b"
+
+        job = timelapse_jobs.start_job(1, run)
+        await started.wait()
+
+        assert await timelapse_jobs.cancel_job(1) is True
+        assert job.status == timelapse_jobs.CANCELLED
+        assert stopped == [True]
+        assert await timelapse_jobs.cancel_job(1) is False
+
+    @pytest.mark.asyncio
+    async def test_shutdown_cancels_every_running_job(self):
+        async def run(job):
+            await asyncio.sleep(30)
+            return "a", "b"
+
+        jobs = [timelapse_jobs.start_job(i, run) for i in (1, 2)]
+        await asyncio.sleep(0)
+
+        await timelapse_jobs.cancel_all()
+
+        assert [j.status for j in jobs] == [timelapse_jobs.CANCELLED] * 2
+
+    @pytest.mark.asyncio
+    async def test_no_job_to_cancel(self):
+        assert await timelapse_jobs.cancel_job(7) is False
+
+
+def _fake_ffmpeg(tmp_path: Path, body: str) -> str:
+    """A stand-in ffmpeg: a Python script; the output path is its last argument."""
+    script = tmp_path / "ffmpeg"
+    script.write_text(f"#!{sys.executable}\nimport sys, time\nout = sys.argv[-1]\n{body}\n")
+    script.chmod(script.stat().st_mode | stat.S_IEXEC)
+    return str(script)
+
+
+def _processor(tmp_path: Path, ffmpeg: str) -> TimelapseProcessor:
+    processor = TimelapseProcessor.__new__(TimelapseProcessor)
+    processor.input_path = tmp_path / "in.mp4"
+    processor.ffmpeg = ffmpeg
+    processor.ffprobe = ffmpeg
+    return processor
+
+
+class TestProcessor:
+    @pytest.mark.asyncio
+    async def test_progress_is_the_share_of_the_output_written(self, tmp_path):
+        ffmpeg = _fake_ffmpeg(
+            tmp_path,
+            "open(out, 'wb').write(b'x')\n"
+            "for us in ('N/A', '1000000', '2000000'):\n"
+            "    print(f'out_time_us={us}', flush=True)\n"
+            "    print('progress=continue', flush=True)",
+        )
+        seen: list[float] = []
+
+        # 8 s of input at double speed: 4 s of output.
+        ok = await _processor(tmp_path, ffmpeg).process(
+            tmp_path / "out.mp4", trim_start=2, trim_end=10, speed=2.0, on_progress=seen.append
+        )
+
+        assert ok is True
+        assert seen == [0.25, 0.5]
+
+    @pytest.mark.asyncio
+    async def test_cancelling_stops_ffmpeg_and_drops_the_partial_file(self, tmp_path):
+        ffmpeg = _fake_ffmpeg(
+            tmp_path,
+            "open(out, 'wb').write(b'partial')\nprint('out_time_us=1000000', flush=True)\ntime.sleep(60)",
+        )
+        output = tmp_path / "out.mp4"
+        reported = asyncio.Event()
+
+        task = asyncio.create_task(
+            _processor(tmp_path, ffmpeg).process(output, trim_end=10, on_progress=lambda _: reported.set())
+        )
+        await asyncio.wait_for(reported.wait(), 10)
+        task.cancel()
+
+        with pytest.raises(asyncio.CancelledError):
+            await asyncio.wait_for(task, 5)
+        assert not output.exists()
+
+    @pytest.mark.asyncio
+    async def test_a_probe_that_hangs_is_killed(self):
+        sleep = shutil.which("sleep")
+        if sleep is None:
+            pytest.skip("no sleep binary")
+        process = await asyncio.create_subprocess_exec(
+            sleep, "30", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
+        )
+
+        # Killed, not waited out: well inside the 30 s the process would run.
+        with pytest.raises(TimeoutError):
+            await asyncio.wait_for(_communicate(process, 0.2), 5)
+
+        assert process.returncode is not None
+
+
+@pytest.mark.asyncio
+async def test_without_a_known_length_it_encodes_without_progress(tmp_path):
+    ffmpeg = _fake_ffmpeg(tmp_path, "open(out, 'wb').write(b'x')\nprint('out_time_us=1000000', flush=True)")
+    processor = _processor(tmp_path, ffmpeg)
+    processor.ffprobe = "/nonexistent/ffprobe"
+    seen: list[float] = []
+
+    ok = await processor.process(tmp_path / "out.mp4", on_progress=seen.append)
+
+    assert ok is True
+    assert seen == []

+ 56 - 0
backend/tests/unit/services/test_usb_camera_listing.py

@@ -0,0 +1,56 @@
+"""The USB camera list the camera settings pick a device from.
+
+Only devices a USB camera can stream from are listed (streaming accepts
+/dev/video0 to /dev/video99), and the listing, which runs v4l2-ctl for each
+device, does not hold up the server while it runs.
+"""
+
+import threading
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.external_camera import list_usb_cameras
+
+
+def _devices(tmp_path: Path, *names: str) -> Path:
+    for name in names:
+        (tmp_path / name).touch()
+    return tmp_path
+
+
+def test_only_device_paths_streaming_accepts_are_listed(tmp_path):
+    dev = _devices(tmp_path, "video0", "video12", "video100", "video05", "video-meta", "videoX")
+
+    with patch("backend.app.services.external_camera.shutil.which", return_value=None):
+        cameras = list_usb_cameras(dev_dir=dev)
+
+    assert [camera["device"] for camera in cameras] == ["/dev/video0", "/dev/video12"]
+
+
+def test_paths_are_listed_as_dev_paths(tmp_path):
+    """The entry names the device as the camera settings store it."""
+    dev = _devices(tmp_path, "video2")
+
+    with patch("backend.app.services.external_camera.shutil.which", return_value=None):
+        cameras = list_usb_cameras(dev_dir=dev)
+
+    assert cameras == [{"device": "/dev/video2", "name": "video2", "capabilities": []}]
+
+
+@pytest.mark.asyncio
+async def test_the_listing_runs_off_the_event_loop(async_client: AsyncClient):
+    seen: list[threading.Thread] = []
+
+    def listing():
+        seen.append(threading.current_thread())
+        return [{"device": "/dev/video0", "name": "Webcam", "capabilities": ["capture"]}]
+
+    with patch("backend.app.services.external_camera.list_usb_cameras", side_effect=listing):
+        response = await async_client.get("/api/v1/printers/usb-cameras")
+
+    assert response.status_code == 200
+    assert response.json()["cameras"][0]["device"] == "/dev/video0"
+    assert seen and seen[0] is not threading.main_thread()

+ 12 - 55
backend/tests/unit/services/test_virtual_printer.py

@@ -152,6 +152,18 @@ class TestVirtualPrinterInstance:
         assert status["running"] is False
         assert status["pending_files"] == 0
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("handler", ["_archive_file", "_queue_file", "_add_to_print_queue"])
+    async def test_a_file_that_cannot_be_handled_leaves_the_pending_count(self, instance, handler):
+        """Without a database the file is dropped, and the status stops counting it."""
+        file_path = Path("/tmp/test.3mf")  # nosec B108
+        instance._session_factory = None
+        instance._pending_files = {file_path.name: file_path}
+
+        await getattr(instance, handler)(file_path, "192.168.1.100")
+
+        assert instance.get_status()["pending_files"] == 0
+
     # ========================================================================
     # Tests for file handling
     # ========================================================================
@@ -3181,27 +3193,6 @@ class TestVirtualPrinterManager:
         assert status["model_name"] == "P1P"
         assert status["pending_files"] == 1
 
-    def test_manager_get_all_status(self, manager, tmp_path):
-        """Verify get_all_status returns status for all instances."""
-        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
-
-        for i in range(1, 3):
-            inst = VirtualPrinterInstance(
-                vp_id=i,
-                name=f"VP{i}",
-                mode="archive",
-                model="C11",
-                access_code="12345678",
-                serial_suffix=f"39180000{i}",
-                base_dir=tmp_path,
-            )
-            manager._instances[i] = inst
-
-        statuses = manager.get_all_status()
-        assert len(statuses) == 2
-        assert statuses[0]["name"] == "VP1"
-        assert statuses[1]["name"] == "VP2"
-
     @pytest.mark.asyncio
     async def test_manager_stop_all(self, manager, tmp_path):
         """Verify stop_all removes all instances."""
@@ -3431,7 +3422,6 @@ class TestVirtualPrinterManager:
             model="C11",
             access_code="12345678",
             serial_suffix="391800001",
-            tailscale_disabled=False,
             base_dir=tmp_path,
         )
         inst.stop_server = AsyncMock()
@@ -3887,39 +3877,6 @@ class TestCertificateService:
         key_content = key_path.read_text()
         assert "BEGIN" in key_content and "KEY" in key_content
 
-    def test_certificates_reused_if_exist(self, cert_service):
-        """Verify existing certificates are reused."""
-        # First generation
-        cert_path1, key_path1 = cert_service.generate_certificates()
-        mtime1 = cert_path1.stat().st_mtime
-
-        # Second call should reuse (via ensure_certificates)
-        cert_path2, key_path2 = cert_service.ensure_certificates()
-        mtime2 = cert_path2.stat().st_mtime
-
-        assert mtime1 == mtime2  # File wasn't regenerated
-
-    def test_delete_certificates(self, cert_service):
-        """Verify certificates can be deleted."""
-        cert_service.generate_certificates()
-
-        assert cert_service.cert_path.exists()
-        assert cert_service.key_path.exists()
-
-        cert_service.delete_certificates()
-
-        assert not cert_service.cert_path.exists()
-        assert not cert_service.key_path.exists()
-
-    def test_ensure_creates_if_not_exist(self, cert_service):
-        """Verify ensure_certificates generates if not existing."""
-        assert not cert_service.cert_path.exists()
-
-        cert_path, key_path = cert_service.ensure_certificates()
-
-        assert cert_path.exists()
-        assert key_path.exists()
-
     # --- CA identity (#3014) ------------------------------------------------
     #
     # A slicer trust store is a flat list of certificates, and OpenSSL resolves

+ 7 - 5
backend/tests/unit/test_filament_type_equivalence.py

@@ -20,11 +20,13 @@ import pytest
 
 from backend.app.services.pipeline_eligibility import _canonical as eligibility_canonical
 from backend.app.services.print_scheduler import canonical_filament_type as scheduler_canonical
-from backend.app.utils.filament_types import (
-    FILAMENT_TYPE_GROUPS,
-    canonical_filament_type,
-    filament_types_compatible,
-)
+from backend.app.utils.filament_types import FILAMENT_TYPE_GROUPS, canonical_filament_type
+
+
+def filament_types_compatible(a: str | None, b: str | None) -> bool:
+    """The comparison the matcher and the pre-flight make."""
+    return canonical_filament_type(a) == canonical_filament_type(b)
+
 
 # Every type either module has ever had an opinion about, plus the shapes the
 # printer actually emits.

+ 1 - 2
backend/tests/unit/test_github_restore.py

@@ -594,11 +594,10 @@ class TestCompanionCredentials:
         rather than judged on availability. See
         ``test_the_whole_ldap_family_is_protected``.
         """
-        await self._restore(db_session, mqtt_enabled="true", virtual_printer_enabled="true")
+        await self._restore(db_session, mqtt_enabled="true")
 
         rows = await self._rows(db_session)
         assert rows["mqtt_enabled"] == "true"
-        assert rows["virtual_printer_enabled"] == "true"
 
     def test_every_exposure_toggle_is_a_companion_toggle(self):
         assert _COMPANION_EXPOSURE_TOGGLES.issubset(_COMPANION_CREDENTIALS)

+ 0 - 38
backend/tests/unit/test_permissions_stats_filter.py

@@ -88,41 +88,3 @@ class TestValidateUserFilterPermission:
         user.has_permission.return_value = False
         with pytest.raises(HTTPException):
             validate(user, -1)
-
-
-class TestApplyUserFilter:
-    """Test the _apply_user_filter helper."""
-
-    @pytest.fixture
-    def apply_filter(self):
-        from backend.app.api.routes.archives import _apply_user_filter
-
-        return _apply_user_filter
-
-    def test_none_does_nothing(self, apply_filter):
-        """When created_by_id is None, conditions list should not change."""
-        conditions = []
-        apply_filter(conditions, None)
-        assert len(conditions) == 0
-
-    def test_positive_id_adds_filter(self, apply_filter):
-        """A positive user ID should add an equality filter."""
-        conditions = []
-        apply_filter(conditions, 5)
-        assert len(conditions) == 1
-        # Check it's a SQLAlchemy comparison expression
-        assert str(conditions[0]) == "print_archives.created_by_id = :created_by_id_1"
-
-    def test_minus_one_adds_is_null(self, apply_filter):
-        """The sentinel value -1 should add an IS NULL filter."""
-        conditions = []
-        apply_filter(conditions, -1)
-        assert len(conditions) == 1
-        assert "IS NULL" in str(conditions[0]).upper()
-
-    def test_appends_to_existing_conditions(self, apply_filter):
-        """Filter should be appended to existing conditions."""
-        conditions = ["existing"]
-        apply_filter(conditions, 5)
-        assert len(conditions) == 2
-        assert conditions[0] == "existing"

+ 0 - 1
backend/tests/unit/test_printer_scope.py

@@ -22,7 +22,6 @@ class TestPrinterScope:
         scope = PrinterScope(frozenset({1, 2}))
         assert scope.allows(1)
         assert not scope.allows(3)
-        assert scope.filter_ids([3, 2, 1]) == [2, 1]
 
     def test_no_printer_is_always_in_scope(self):
         # Rows not bound to a printer (orphaned archives, model-based jobs)

+ 1 - 39
backend/tests/unit/test_spoolbuddy_schema_validation.py

@@ -1,4 +1,4 @@
-"""Unit tests for SpoolBuddy schema validation (security fixes H2, M2, M4).
+"""Unit tests for SpoolBuddy schema validation (security fixes M2, M4).
 
 Tests Pydantic model validation without requiring a running server or DB.
 """
@@ -10,47 +10,9 @@ from backend.app.schemas.spoolbuddy import (
     DeviceRegisterRequest,
     HeartbeatRequest,
     ScaleReadingRequest,
-    UpdateStatusRequest,
     WriteTagResultRequest,
 )
 
-# ---------------------------------------------------------------------------
-# H2 — UpdateStatusRequest: only valid Literal values accepted
-# ---------------------------------------------------------------------------
-
-
-class TestUpdateStatusRequestValidation:
-    def test_valid_status_updating(self):
-        req = UpdateStatusRequest(status="updating")
-        assert req.status == "updating"
-
-    def test_valid_status_complete(self):
-        req = UpdateStatusRequest(status="complete")
-        assert req.status == "complete"
-
-    def test_valid_status_error(self):
-        req = UpdateStatusRequest(status="error")
-        assert req.status == "error"
-
-    def test_invalid_status_rejected(self):
-        """Arbitrary status strings must be rejected (H2: prevents unbounded WS injection)."""
-        with pytest.raises(ValidationError):
-            UpdateStatusRequest(status="hacked")
-
-    def test_empty_status_rejected(self):
-        with pytest.raises(ValidationError):
-            UpdateStatusRequest(status="")
-
-    def test_message_max_length_enforced(self):
-        """message field must not exceed 255 chars."""
-        with pytest.raises(ValidationError):
-            UpdateStatusRequest(status="updating", message="x" * 256)
-
-    def test_message_at_max_length_accepted(self):
-        req = UpdateStatusRequest(status="complete", message="x" * 255)
-        assert len(req.message) == 255
-
-
 # ---------------------------------------------------------------------------
 # M2 — HeartbeatRequest: system_stats size limit (4096 bytes)
 # ---------------------------------------------------------------------------

+ 0 - 186
backend/tests/unit/test_spoolman_clear_location.py

@@ -1,186 +0,0 @@
-"""Unit tests for Spoolman location clearing when spools are removed from AMS.
-
-Tests the clear_location_for_removed_spools method to verify that stale
-Spoolman locations are cleared during both auto-sync and manual sync,
-preventing the "double-booked" slot bug (#921).
-"""
-
-from unittest.mock import AsyncMock, patch
-
-import pytest
-
-from backend.app.services.spoolman import SpoolmanClient
-
-BAMBU_UUID_A = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
-BAMBU_UUID_B = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
-BAMBU_UUID_C = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"
-PRINTER_NAME = "My Printer"
-LOCATION_PREFIX = f"{PRINTER_NAME} - "
-
-
-def _make_spool(spool_id: int, location: str, tag: str = "", extra: dict | None = None) -> dict:
-    """Create a mock Spoolman spool dict."""
-    return {
-        "id": spool_id,
-        "location": location,
-        "extra": extra or {"tag": tag},
-    }
-
-
-@pytest.fixture
-def client():
-    """Create a SpoolmanClient without connecting."""
-    return SpoolmanClient("http://localhost:7912")
-
-
-class TestClearLocationForRemovedSpools:
-    """Test the clear_location_for_removed_spools method."""
-
-    @pytest.mark.asyncio
-    async def test_clears_spool_no_longer_in_ams(self, client):
-        """A spool whose UUID is not in current_tray_uuids should have its location cleared."""
-        cached_spools = [
-            _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A),
-        ]
-
-        with patch.object(client, "update_spool", new_callable=AsyncMock, return_value=True) as mock_update:
-            cleared = await client.clear_location_for_removed_spools(
-                PRINTER_NAME, current_tray_uuids=set(), cached_spools=cached_spools
-            )
-
-        assert cleared == 1
-        mock_update.assert_called_once_with(spool_id=1, clear_location=True)
-
-    @pytest.mark.asyncio
-    async def test_keeps_spool_still_in_ams(self, client):
-        """A spool whose UUID is in current_tray_uuids should not be cleared."""
-        cached_spools = [
-            _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A),
-        ]
-
-        with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
-            cleared = await client.clear_location_for_removed_spools(
-                PRINTER_NAME, current_tray_uuids={BAMBU_UUID_A}, cached_spools=cached_spools
-            )
-
-        assert cleared == 0
-        mock_update.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_skips_non_bambu_spools(self, client):
-        """Spools without a 32-char RFID tag should not be cleared (non-Bambu / third-party)."""
-        cached_spools = [
-            _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", "SHORT_TAG"),
-            _make_spool(2, f"{LOCATION_PREFIX}AMS A Slot 2", ""),
-        ]
-
-        with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
-            cleared = await client.clear_location_for_removed_spools(
-                PRINTER_NAME, current_tray_uuids=set(), cached_spools=cached_spools
-            )
-
-        assert cleared == 0
-        mock_update.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_skips_spools_from_other_printers(self, client):
-        """Spools with locations for a different printer should not be touched."""
-        cached_spools = [
-            _make_spool(1, "Other Printer - AMS A Slot 1", BAMBU_UUID_A),
-        ]
-
-        with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
-            cleared = await client.clear_location_for_removed_spools(
-                PRINTER_NAME, current_tray_uuids=set(), cached_spools=cached_spools
-            )
-
-        assert cleared == 0
-        mock_update.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_synced_spool_ids_protects_location_matched_spools(self, client):
-        """Spools in synced_spool_ids should not be cleared even if UUID doesn't match."""
-        cached_spools = [
-            _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A),
-        ]
-
-        with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
-            cleared = await client.clear_location_for_removed_spools(
-                PRINTER_NAME,
-                current_tray_uuids=set(),
-                cached_spools=cached_spools,
-                synced_spool_ids={1},
-            )
-
-        assert cleared == 0
-        mock_update.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_clears_only_removed_spools_in_mixed_set(self, client):
-        """With multiple spools at a printer, only clear the one that was removed."""
-        cached_spools = [
-            _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A),  # Still in AMS
-            _make_spool(2, f"{LOCATION_PREFIX}AMS A Slot 2", BAMBU_UUID_B),  # Removed
-            _make_spool(3, f"{LOCATION_PREFIX}AMS A Slot 3", BAMBU_UUID_C),  # Still in AMS
-        ]
-
-        with patch.object(client, "update_spool", new_callable=AsyncMock, return_value=True) as mock_update:
-            cleared = await client.clear_location_for_removed_spools(
-                PRINTER_NAME,
-                current_tray_uuids={BAMBU_UUID_A, BAMBU_UUID_C},
-                cached_spools=cached_spools,
-            )
-
-        assert cleared == 1
-        mock_update.assert_called_once_with(spool_id=2, clear_location=True)
-
-    @pytest.mark.asyncio
-    async def test_uuid_comparison_is_case_insensitive(self, client):
-        """UUID matching should work regardless of case."""
-        cached_spools = [
-            _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A.lower()),
-        ]
-
-        with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
-            cleared = await client.clear_location_for_removed_spools(
-                PRINTER_NAME,
-                current_tray_uuids={BAMBU_UUID_A},  # Uppercase
-                cached_spools=cached_spools,
-            )
-
-        assert cleared == 0
-        mock_update.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_returns_zero_when_no_spools_at_printer(self, client):
-        """When no spools have locations for this printer, nothing is cleared."""
-        with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
-            cleared = await client.clear_location_for_removed_spools(
-                PRINTER_NAME, current_tray_uuids=set(), cached_spools=[]
-            )
-
-        assert cleared == 0
-        mock_update.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_double_booking_scenario(self, client):
-        """Reproduce #921: two spools assigned to the same printer location.
-
-        When SpoolA is removed and SpoolB takes its slot, SpoolA's old location
-        should be cleared because its UUID is no longer in current_tray_uuids.
-        """
-        cached_spools = [
-            _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A),  # OLD — was removed
-            _make_spool(2, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_B),  # NEW — just inserted
-        ]
-
-        with patch.object(client, "update_spool", new_callable=AsyncMock, return_value=True) as mock_update:
-            cleared = await client.clear_location_for_removed_spools(
-                PRINTER_NAME,
-                current_tray_uuids={BAMBU_UUID_B},  # Only SpoolB is in AMS now
-                cached_spools=cached_spools,
-                synced_spool_ids={2},  # SpoolB was just synced
-            )
-
-        assert cleared == 1
-        mock_update.assert_called_once_with(spool_id=1, clear_location=True)

+ 0 - 40
backend/tests/unit/test_support_helpers.py

@@ -790,46 +790,6 @@ class TestParseObicoEnabledPrinters:
         assert _parse_obico_enabled_printers('{"1": true}') == set()
 
 
-class TestCheckUrlReachable:
-    """Tests for the slicer-API reachability ping."""
-
-    @pytest.mark.asyncio
-    async def test_empty_url_returns_none(self):
-        from backend.app.api.routes.support import _check_url_reachable
-
-        assert await _check_url_reachable("") is None
-        assert await _check_url_reachable("   ") is None
-
-    @pytest.mark.asyncio
-    async def test_successful_response_is_reachable_even_on_404(self):
-        # A 404 means the API is up; we want to separate network failure from
-        # configuration mistakes, so non-empty status counts as reachable.
-        from backend.app.api.routes.support import _check_url_reachable
-
-        with patch("httpx.AsyncClient") as mock_client_cls:
-            mock_client = AsyncMock()
-            mock_client_cls.return_value.__aenter__.return_value = mock_client
-            mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
-            mock_response = MagicMock()
-            mock_response.status_code = 404
-            mock_client.get = AsyncMock(return_value=mock_response)
-
-            result = await _check_url_reachable("http://localhost:3001/api")
-
-        assert result is True
-
-    @pytest.mark.asyncio
-    async def test_connection_error_returns_false(self):
-        from backend.app.api.routes.support import _check_url_reachable
-
-        with patch("httpx.AsyncClient") as mock_client_cls:
-            mock_client_cls.return_value.__aenter__.side_effect = ConnectionError("boom")
-
-            result = await _check_url_reachable("http://nowhere:9999")
-
-        assert result is False
-
-
 class TestFetchSlicerHealth:
     """Tests for the slicer-API health probe that extracts the bundled CLI
     version. Knowing the version in the support bundle lets the reviewer

+ 13 - 13
backend/tests/unit/test_threemf_tools.py

@@ -13,12 +13,12 @@ import pytest
 
 from backend.app.utils.threemf_tools import (
     expand_to_project_slots,
-    extract_bed_type_from_3mf,
     extract_embedded_presets_from_3mf,
     extract_filament_usage_from_3mf,
     extract_layer_filament_usage_from_3mf,
     extract_max_z_height_from_3mf,
     extract_plate_extruder_set_from_3mf,
+    extract_plate_metadata_from_3mf,
     extract_print_time_from_3mf,
     extract_project_filaments_from_3mf,
     extract_support_filament_slots_from_3mf,
@@ -816,7 +816,7 @@ class TestExtractEmbeddedPresetsFrom3mf:
 
 
 class TestExtractBedTypeFrom3mf:
-    """extract_bed_type_from_3mf reads per-plate `curr_bed_type` from
+    """extract_plate_metadata_from_3mf reads per-plate `curr_bed_type` from
     slice_info.config so the queue / print modal can show the right plate
     even on multi-plate 3MFs where different plates target different beds
     (#1281). archive.bed_type is one-value-per-archive (first plate's
@@ -835,7 +835,7 @@ class TestExtractBedTypeFrom3mf:
         file_path = tmp_path / "test.3mf"
         file_path.write_bytes(create_mock_3mf(xml_content).read())
 
-        assert extract_bed_type_from_3mf(file_path) == "Textured PEI Plate"
+        assert extract_plate_metadata_from_3mf(file_path).bed_type == "Textured PEI Plate"
 
     def test_multi_plate_returns_per_plate_value(self, tmp_path):
         # Reporter's case: a 3MF mixing PEI + Engineering across plates.
@@ -860,9 +860,9 @@ class TestExtractBedTypeFrom3mf:
         file_path = tmp_path / "test.3mf"
         file_path.write_bytes(create_mock_3mf(xml_content).read())
 
-        assert extract_bed_type_from_3mf(file_path, plate_id=1) == "Textured PEI Plate"
-        assert extract_bed_type_from_3mf(file_path, plate_id=2) == "Engineering Plate"
-        assert extract_bed_type_from_3mf(file_path, plate_id=3) == "Cool Plate"
+        assert extract_plate_metadata_from_3mf(file_path, plate_id=1).bed_type == "Textured PEI Plate"
+        assert extract_plate_metadata_from_3mf(file_path, plate_id=2).bed_type == "Engineering Plate"
+        assert extract_plate_metadata_from_3mf(file_path, plate_id=3).bed_type == "Cool Plate"
 
     def test_no_plate_id_returns_first_plate(self, tmp_path):
         # The plate_id=None branch must match the archive-level capture
@@ -883,7 +883,7 @@ class TestExtractBedTypeFrom3mf:
         file_path = tmp_path / "test.3mf"
         file_path.write_bytes(create_mock_3mf(xml_content).read())
 
-        assert extract_bed_type_from_3mf(file_path) == "Cool Plate SuperTack"
+        assert extract_plate_metadata_from_3mf(file_path).bed_type == "Cool Plate SuperTack"
 
     def test_unknown_plate_id_returns_none(self, tmp_path):
         xml_content = """<?xml version="1.0" encoding="UTF-8"?>
@@ -897,7 +897,7 @@ class TestExtractBedTypeFrom3mf:
         file_path = tmp_path / "test.3mf"
         file_path.write_bytes(create_mock_3mf(xml_content).read())
 
-        assert extract_bed_type_from_3mf(file_path, plate_id=99) is None
+        assert extract_plate_metadata_from_3mf(file_path, plate_id=99).bed_type is None
 
     def test_plate_without_bed_type_returns_none(self, tmp_path):
         # Older slicers may export a plate without curr_bed_type. The
@@ -917,8 +917,8 @@ class TestExtractBedTypeFrom3mf:
         file_path = tmp_path / "test.3mf"
         file_path.write_bytes(create_mock_3mf(xml_content).read())
 
-        assert extract_bed_type_from_3mf(file_path, plate_id=1) is None
-        assert extract_bed_type_from_3mf(file_path, plate_id=2) == "Engineering Plate"
+        assert extract_plate_metadata_from_3mf(file_path, plate_id=1).bed_type is None
+        assert extract_plate_metadata_from_3mf(file_path, plate_id=2).bed_type == "Engineering Plate"
 
     def test_missing_slice_info_returns_none(self, tmp_path):
         buffer = io.BytesIO()
@@ -929,13 +929,13 @@ class TestExtractBedTypeFrom3mf:
         file_path = tmp_path / "test.3mf"
         file_path.write_bytes(buffer.read())
 
-        assert extract_bed_type_from_3mf(file_path) is None
+        assert extract_plate_metadata_from_3mf(file_path).bed_type is None
 
     def test_invalid_file_returns_none(self, tmp_path):
         file_path = tmp_path / "invalid.3mf"
         file_path.write_text("not a zip file")
 
-        assert extract_bed_type_from_3mf(file_path) is None
+        assert extract_plate_metadata_from_3mf(file_path).bed_type is None
 
     def test_whitespace_trimmed(self, tmp_path):
         # 3MF values sometimes carry surrounding whitespace from manual
@@ -952,7 +952,7 @@ class TestExtractBedTypeFrom3mf:
         file_path = tmp_path / "test.3mf"
         file_path.write_bytes(create_mock_3mf(xml_content).read())
 
-        assert extract_bed_type_from_3mf(file_path) == "Textured PEI Plate"
+        assert extract_plate_metadata_from_3mf(file_path).bed_type == "Textured PEI Plate"
 
 
 class TestExtractPrintTimeFrom3mf:

+ 0 - 76
backend/tests/unit/test_vp_certificate_rotation.py

@@ -1,76 +0,0 @@
-"""Tests for CertificateService.ensure_certificates' CA-rotation guard.
-
-When the shared CA is regenerated (e.g. its expiry crossed
-``CA_EXPIRY_THRESHOLD_DAYS``), any per-VP printer certificate that was
-signed by the OLD CA becomes orphaned: it still exists on disk and the
-old fallback ``cert_path.exists()`` check would happily reuse it. A
-slicer that imported the NEW CA then fails the TLS handshake because
-the printer cert's issuer doesn't match anything in its trust store.
-
-``_cert_matches_current_ca`` is the guard. It compares the on-disk
-printer cert's issuer against the on-disk CA cert's subject; on
-mismatch ``ensure_certificates`` regenerates the per-VP cert under the
-current CA.
-"""
-
-from backend.app.services.virtual_printer.certificate import CertificateService
-
-
-def test_ensure_certificates_reuses_cert_when_issuer_matches_ca(tmp_path):
-    """Happy path: a freshly-generated CA + per-VP cert pair shares
-    issuer/subject. ``ensure_certificates`` reads them back without
-    regenerating."""
-    svc = CertificateService(cert_dir=tmp_path, serial="01P00A391800001")
-
-    # First call: generates the CA + per-VP cert from scratch.
-    first_cert, first_key = svc.ensure_certificates()
-    first_cert_bytes = first_cert.read_bytes()
-
-    # Second call: cert + CA exist and the issuer matches. Should reuse.
-    second_cert, _ = svc.ensure_certificates()
-    assert second_cert.read_bytes() == first_cert_bytes
-
-
-def test_ensure_certificates_regenerates_when_ca_rotated(tmp_path):
-    """CA rotation scenario: the CA file is replaced with a different one
-    (e.g. previous expired and was regenerated). The per-VP cert on disk
-    was signed by the old CA, so its issuer no longer matches the new CA's
-    subject. ``ensure_certificates`` must regenerate the per-VP cert."""
-    # Build the first pair.
-    svc1 = CertificateService(cert_dir=tmp_path, serial="01P00A391800001")
-    orig_cert_bytes = svc1.ensure_certificates()[0].read_bytes()
-    orig_ca_bytes = svc1.ca_cert_path.read_bytes()
-
-    # Simulate CA rotation: build a SECOND CA in a different dir, then
-    # swap that CA's files into the original CA path. The per-VP cert
-    # still on disk was signed by the original CA — issuer mismatch now.
-    rotated_dir = tmp_path / "rotated"
-    rotated_dir.mkdir()
-    svc_rotated = CertificateService(cert_dir=rotated_dir, serial="01P00A391800002")
-    svc_rotated.ensure_certificates()
-
-    # Overwrite the original CA on disk with the rotated one.
-    svc1.ca_cert_path.write_bytes(svc_rotated.ca_cert_path.read_bytes())
-    svc1.ca_key_path.write_bytes(svc_rotated.ca_key_path.read_bytes())
-    assert svc1.ca_cert_path.read_bytes() != orig_ca_bytes  # confirm rotation
-
-    # Build a fresh service against the rotated CA, then ensure_certificates
-    # should detect the mismatch and regenerate the per-VP cert.
-    svc2 = CertificateService(cert_dir=tmp_path, serial="01P00A391800001")
-    new_cert, _ = svc2.ensure_certificates()
-    new_cert_bytes = new_cert.read_bytes()
-
-    # New per-VP cert must differ from the old (signed by a different CA now).
-    assert new_cert_bytes != orig_cert_bytes
-
-
-def test_cert_matches_current_ca_returns_false_when_no_ca(tmp_path):
-    """If the CA file is missing entirely, the match check must return
-    False so ``ensure_certificates`` falls through to ``generate_certificates``
-    instead of returning a per-VP cert that nothing can validate."""
-    svc = CertificateService(cert_dir=tmp_path, serial="01P00A391800001")
-    # Write a per-VP cert without a CA.
-    svc.cert_path.write_bytes(b"fake cert content")
-    svc.key_path.write_bytes(b"fake key content")
-    # No bbl_ca.crt on disk → match fails safely.
-    assert svc._cert_matches_current_ca() is False

+ 1 - 1
backend/tests/unit/test_vp_tls_ciphers.py

@@ -46,7 +46,7 @@ def _cert_pair(tmp_path: Path) -> tuple[Path, Path]:
     CertificateService. Returns ``(cert_path, key_path)`` suitable for
     ``load_cert_chain`` calls inside the VP services under test."""
     svc = CertificateService(cert_dir=tmp_path, serial="01P00A391800001")
-    return svc.ensure_certificates()
+    return svc.generate_certificates()
 
 
 def _assert_required_ciphers(ctx: ssl.SSLContext, where: str) -> None:

+ 2 - 2
docker-compose.test.yml

@@ -62,9 +62,9 @@ services:
     environment:
       - BAMBUDDY_TEST_URL=http://integration:8000
       - TESTING=1
-    # -v dropped + -n auto so integration tests inherit the same noise /
+    # -v dropped + -n 30 so integration tests inherit the same noise /
     # parallelism profile as the backend-test image (see Dockerfile.test CMD).
-    command: ["pytest", "backend/tests/integration/", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"]
+    command: ["pytest", "backend/tests/integration/", "--tb=short", "-p", "no:cacheprovider", "-n", "30"]
     init: true
     volumes:
       - ./backend:/app/backend:ro

+ 132 - 102
frontend/package-lock.json

@@ -74,7 +74,7 @@
         "typescript": "~5.9.3",
         "typescript-eslint": "^8.46.4",
         "unified": "^11.0.5",
-        "vite": "^8.0.16",
+        "vite": "^8.1.6",
         "vitest": "^4.1.11"
       }
     },
@@ -579,31 +579,34 @@
       }
     },
     "node_modules/@emnapi/core": {
-      "version": "1.10.0",
-      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
-      "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+      "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
       "dev": true,
+      "license": "MIT",
       "optional": true,
       "dependencies": {
-        "@emnapi/wasi-threads": "1.2.1",
+        "@emnapi/wasi-threads": "1.2.2",
         "tslib": "^2.4.0"
       }
     },
     "node_modules/@emnapi/runtime": {
-      "version": "1.10.0",
-      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
-      "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+      "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
       "dev": true,
+      "license": "MIT",
       "optional": true,
       "dependencies": {
         "tslib": "^2.4.0"
       }
     },
     "node_modules/@emnapi/wasi-threads": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
-      "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+      "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
       "dev": true,
+      "license": "MIT",
       "optional": true,
       "dependencies": {
         "tslib": "^2.4.0"
@@ -1614,10 +1617,11 @@
       }
     },
     "node_modules/@oxc-project/types": {
-      "version": "0.133.0",
-      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
-      "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
+      "version": "0.139.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+      "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
       "dev": true,
+      "license": "MIT",
       "funding": {
         "url": "https://github.com/sponsors/Boshen"
       }
@@ -1869,16 +1873,6 @@
         "tslib": "^2.4.0"
       }
     },
-    "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
-      "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
-      "dev": true,
-      "optional": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
     "node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
       "version": "11.24.2",
       "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz",
@@ -1942,13 +1936,14 @@
       }
     },
     "node_modules/@rolldown/binding-android-arm64": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
-      "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+      "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
+      "license": "MIT",
       "optional": true,
       "os": [
         "android"
@@ -1958,13 +1953,14 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-arm64": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
-      "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+      "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
+      "license": "MIT",
       "optional": true,
       "os": [
         "darwin"
@@ -1974,13 +1970,14 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-x64": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
-      "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+      "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
       "cpu": [
         "x64"
       ],
       "dev": true,
+      "license": "MIT",
       "optional": true,
       "os": [
         "darwin"
@@ -1990,13 +1987,14 @@
       }
     },
     "node_modules/@rolldown/binding-freebsd-x64": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
-      "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+      "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
       "cpu": [
         "x64"
       ],
       "dev": true,
+      "license": "MIT",
       "optional": true,
       "os": [
         "freebsd"
@@ -2006,13 +2004,14 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
-      "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+      "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
       "cpu": [
         "arm"
       ],
       "dev": true,
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
@@ -2022,13 +2021,17 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-gnu": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
-      "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+      "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
@@ -2038,13 +2041,17 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-musl": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
-      "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+      "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
@@ -2054,13 +2061,17 @@
       }
     },
     "node_modules/@rolldown/binding-linux-ppc64-gnu": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
-      "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+      "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
       "cpu": [
         "ppc64"
       ],
       "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
@@ -2070,13 +2081,17 @@
       }
     },
     "node_modules/@rolldown/binding-linux-s390x-gnu": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
-      "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+      "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
       "cpu": [
         "s390x"
       ],
       "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
@@ -2086,13 +2101,17 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-gnu": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
-      "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+      "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
       "cpu": [
         "x64"
       ],
       "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
@@ -2102,13 +2121,17 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-musl": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
-      "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+      "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
       "cpu": [
         "x64"
       ],
       "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
@@ -2118,13 +2141,14 @@
       }
     },
     "node_modules/@rolldown/binding-openharmony-arm64": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
-      "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+      "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
+      "license": "MIT",
       "optional": true,
       "os": [
         "openharmony"
@@ -2134,31 +2158,33 @@
       }
     },
     "node_modules/@rolldown/binding-wasm32-wasi": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
-      "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+      "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
       "cpu": [
         "wasm32"
       ],
       "dev": true,
+      "license": "MIT",
       "optional": true,
       "dependencies": {
-        "@emnapi/core": "1.10.0",
-        "@emnapi/runtime": "1.10.0",
-        "@napi-rs/wasm-runtime": "^1.1.4"
+        "@emnapi/core": "1.11.1",
+        "@emnapi/runtime": "1.11.1",
+        "@napi-rs/wasm-runtime": "^1.1.6"
       },
       "engines": {
         "node": "^20.19.0 || >=22.12.0"
       }
     },
     "node_modules/@rolldown/binding-win32-arm64-msvc": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
-      "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+      "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
+      "license": "MIT",
       "optional": true,
       "os": [
         "win32"
@@ -2168,13 +2194,14 @@
       }
     },
     "node_modules/@rolldown/binding-win32-x64-msvc": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
-      "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+      "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
       "cpu": [
         "x64"
       ],
       "dev": true,
+      "license": "MIT",
       "optional": true,
       "os": [
         "win32"
@@ -8023,12 +8050,13 @@
       "license": "MIT"
     },
     "node_modules/rolldown": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
-      "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+      "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
-        "@oxc-project/types": "=0.133.0",
+        "@oxc-project/types": "=0.139.0",
         "@rolldown/pluginutils": "^1.0.0"
       },
       "bin": {
@@ -8038,28 +8066,29 @@
         "node": "^20.19.0 || >=22.12.0"
       },
       "optionalDependencies": {
-        "@rolldown/binding-android-arm64": "1.0.3",
-        "@rolldown/binding-darwin-arm64": "1.0.3",
-        "@rolldown/binding-darwin-x64": "1.0.3",
-        "@rolldown/binding-freebsd-x64": "1.0.3",
-        "@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
-        "@rolldown/binding-linux-arm64-gnu": "1.0.3",
-        "@rolldown/binding-linux-arm64-musl": "1.0.3",
-        "@rolldown/binding-linux-ppc64-gnu": "1.0.3",
-        "@rolldown/binding-linux-s390x-gnu": "1.0.3",
-        "@rolldown/binding-linux-x64-gnu": "1.0.3",
-        "@rolldown/binding-linux-x64-musl": "1.0.3",
-        "@rolldown/binding-openharmony-arm64": "1.0.3",
-        "@rolldown/binding-wasm32-wasi": "1.0.3",
-        "@rolldown/binding-win32-arm64-msvc": "1.0.3",
-        "@rolldown/binding-win32-x64-msvc": "1.0.3"
+        "@rolldown/binding-android-arm64": "1.1.5",
+        "@rolldown/binding-darwin-arm64": "1.1.5",
+        "@rolldown/binding-darwin-x64": "1.1.5",
+        "@rolldown/binding-freebsd-x64": "1.1.5",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+        "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+        "@rolldown/binding-linux-arm64-musl": "1.1.5",
+        "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+        "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+        "@rolldown/binding-linux-x64-gnu": "1.1.5",
+        "@rolldown/binding-linux-x64-musl": "1.1.5",
+        "@rolldown/binding-openharmony-arm64": "1.1.5",
+        "@rolldown/binding-wasm32-wasi": "1.1.5",
+        "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+        "@rolldown/binding-win32-x64-msvc": "1.1.5"
       }
     },
     "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
       "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     "node_modules/rope-sequence": {
       "version": "1.3.4",
@@ -8181,9 +8210,9 @@
       }
     },
     "node_modules/source-map-js": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
-      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+      "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
       "dev": true,
       "license": "BSD-3-Clause",
       "engines": {
@@ -8763,15 +8792,16 @@
       }
     },
     "node_modules/vite": {
-      "version": "8.0.16",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
-      "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
+      "version": "8.1.6",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.6.tgz",
+      "integrity": "sha512-aRAHIxx5SW5zc1sMqFwJU/t5qaFfXb5Hj0J7W0VXihu/X99+346tz6080tO1T1xVIJORX/b3zVTXGFqUXh35uw==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "lightningcss": "^1.32.0",
-        "picomatch": "^4.0.4",
-        "postcss": "^8.5.15",
-        "rolldown": "1.0.3",
+        "picomatch": "^4.0.5",
+        "postcss": "^8.5.17",
+        "rolldown": "~1.1.5",
         "tinyglobby": "^0.2.17"
       },
       "bin": {
@@ -8788,7 +8818,7 @@
       },
       "peerDependencies": {
         "@types/node": "^20.19.0 || >=22.12.0",
-        "@vitejs/devtools": "^0.1.18",
+        "@vitejs/devtools": "^0.3.0",
         "esbuild": "^0.27.0 || ^0.28.0",
         "jiti": ">=1.21.0",
         "less": "^4.0.0",

+ 1 - 1
frontend/package.json

@@ -91,7 +91,7 @@
     "typescript": "~5.9.3",
     "typescript-eslint": "^8.46.4",
     "unified": "^11.0.5",
-    "vite": "^8.0.16",
+    "vite": "^8.1.6",
     "vitest": "^4.1.11"
   }
 }

+ 0 - 3
frontend/src/__tests__/components/AdminDisableTwoFactor.test.tsx

@@ -26,16 +26,13 @@ vi.mock('../../contexts/AuthContext', async (importOriginal) => {
     ...actual,
     useAuth: () => ({
       ...mockAuth,
-      requiresSetup: false,
       loading: false,
       hasPermission: () => true,
       hasAnyPermission: () => true,
-      hasAllPermissions: () => true,
       canModify: () => true,
       login: vi.fn(),
       loginWithToken: vi.fn(),
       logout: vi.fn(),
-      refreshUser: vi.fn(),
       refreshAuth: vi.fn(),
     }),
   };

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

@@ -1,88 +0,0 @@
-/**
- * Tests for the BackupModal component.
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../utils';
-import { BackupModal } from '../../components/BackupModal';
-import { http, HttpResponse } from 'msw';
-import { server } from '../mocks/server';
-
-describe('BackupModal', () => {
-  const mockOnClose = vi.fn();
-
-  beforeEach(() => {
-    vi.clearAllMocks();
-    server.use(
-      http.post('/api/v1/settings/backup', () => {
-        return new HttpResponse(
-          JSON.stringify({ success: true }),
-          {
-            headers: {
-              'Content-Type': 'application/json',
-            },
-          }
-        );
-      })
-    );
-  });
-
-  describe('rendering', () => {
-    it('renders the modal title', () => {
-      render(<BackupModal onClose={mockOnClose} />);
-
-      expect(screen.getByText(/backup/i)).toBeInTheDocument();
-    });
-
-    it('shows backup options', () => {
-      render(<BackupModal onClose={mockOnClose} />);
-
-      expect(screen.getByText(/settings/i)).toBeInTheDocument();
-    });
-
-    it('shows export button', () => {
-      render(<BackupModal onClose={mockOnClose} />);
-
-      expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument();
-    });
-
-    it('shows cancel button', () => {
-      render(<BackupModal onClose={mockOnClose} />);
-
-      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
-    });
-  });
-
-  describe('backup options', () => {
-    it('has checkbox for printers', () => {
-      render(<BackupModal onClose={mockOnClose} />);
-
-      expect(screen.getByText('Printers')).toBeInTheDocument();
-    });
-
-    it('has checkbox for archives', () => {
-      render(<BackupModal onClose={mockOnClose} />);
-
-      expect(screen.getByText(/archives/i)).toBeInTheDocument();
-    });
-
-    it('has checkbox for projects', () => {
-      render(<BackupModal onClose={mockOnClose} />);
-
-      expect(screen.getByText('Projects')).toBeInTheDocument();
-    });
-  });
-
-  describe('actions', () => {
-    it('calls onClose when cancel is clicked', async () => {
-      const user = userEvent.setup();
-      render(<BackupModal onClose={mockOnClose} />);
-
-      await user.click(screen.getByRole('button', { name: /cancel/i }));
-
-      expect(mockOnClose).toHaveBeenCalled();
-    });
-  });
-});

+ 0 - 3
frontend/src/__tests__/components/BulkPrinterToolbarWiring.test.tsx

@@ -18,17 +18,14 @@ const permissions = { granted: [] as string[] };
 const mockUseAuth = {
   user: { id: 1, username: 'operator', permissions: [] as string[] },
   authEnabled: true,
-  requiresSetup: false,
   loading: false,
   isAdmin: false,
   login: vi.fn(),
   loginWithToken: vi.fn(),
   logout: vi.fn(),
-  refreshUser: vi.fn(),
   refreshAuth: vi.fn(),
   hasPermission: vi.fn((permission: string) => permissions.granted.includes(permission)),
   hasAnyPermission: vi.fn(() => true),
-  hasAllPermissions: vi.fn(() => true),
   canModify: vi.fn(() => true),
 };
 

+ 0 - 3
frontend/src/__tests__/components/PrintModalBilling.test.tsx

@@ -17,17 +17,14 @@ const mockShowToast = vi.fn();
 const mockUseAuth = {
   user: { id: 1, username: 'finance-user', permissions: ['cost_centers:read_own', 'printers:control'] },
   authEnabled: true,
-  requiresSetup: false,
   loading: false,
   isAdmin: false,
   login: vi.fn(),
   loginWithToken: vi.fn(),
   logout: vi.fn(),
-  refreshUser: vi.fn(),
   refreshAuth: vi.fn(),
   hasPermission: vi.fn((permission: string) => permission === 'cost_centers:read_own' || permission === 'printers:control'),
   hasAnyPermission: vi.fn(() => true),
-  hasAllPermissions: vi.fn(() => true),
   canModify: vi.fn(() => true),
 };
 

+ 0 - 89
frontend/src/__tests__/components/RestoreModal.test.tsx

@@ -1,89 +0,0 @@
-/**
- * Tests for the RestoreModal component.
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../utils';
-import { RestoreModal } from '../../components/RestoreModal';
-import { http, HttpResponse } from 'msw';
-import { server } from '../mocks/server';
-
-describe('RestoreModal', () => {
-  const mockOnClose = vi.fn();
-  const mockOnRestore = vi.fn();
-  const mockOnSuccess = vi.fn();
-
-  beforeEach(() => {
-    vi.clearAllMocks();
-    server.use(
-      http.post('/api/v1/settings/restore', () => {
-        return HttpResponse.json({ success: true });
-      })
-    );
-  });
-
-  describe('rendering', () => {
-    it('renders the modal title', () => {
-      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);
-
-      // Title is "Restore Backup"
-      expect(screen.getByText('Restore Backup')).toBeInTheDocument();
-    });
-
-    it('shows file upload area', () => {
-      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);
-
-      expect(screen.getByText(/select.*file/i)).toBeInTheDocument();
-    });
-
-    it('shows cancel button', () => {
-      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);
-
-      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
-    });
-  });
-
-  describe('file input', () => {
-    it('accepts backup files', () => {
-      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);
-
-      const fileInput = document.querySelector('input[type="file"]');
-      expect(fileInput).toBeInTheDocument();
-    });
-  });
-
-  describe('actions', () => {
-    it('calls onClose when cancel is clicked', async () => {
-      const user = userEvent.setup();
-      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);
-
-      await user.click(screen.getByRole('button', { name: /cancel/i }));
-
-      expect(mockOnClose).toHaveBeenCalled();
-    });
-  });
-
-  describe('overwrite option', () => {
-    it('has overwrite toggle', () => {
-      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);
-
-      // The modal has toggle for replacing existing data
-      expect(screen.getByText('Keep existing data')).toBeInTheDocument();
-    });
-
-    it('shows warning when overwrite is enabled', async () => {
-      const user = userEvent.setup();
-      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);
-
-      // Find and click the toggle (uses role="switch")
-      const toggle = screen.getByRole('switch');
-      await user.click(toggle);
-
-      await waitFor(() => {
-        expect(screen.getByText(/Caution/)).toBeInTheDocument();
-      });
-    });
-  });
-});

+ 0 - 3
frontend/src/__tests__/components/SkipObjectsModalWiring.test.tsx

@@ -17,17 +17,14 @@ const permissions = { granted: ['printers:control'] as string[] };
 const mockUseAuth = {
   user: { id: 1, username: 'operator', permissions: [] as string[] },
   authEnabled: true,
-  requiresSetup: false,
   loading: false,
   isAdmin: false,
   login: vi.fn(),
   loginWithToken: vi.fn(),
   logout: vi.fn(),
-  refreshUser: vi.fn(),
   refreshAuth: vi.fn(),
   hasPermission: vi.fn((permission: string) => permissions.granted.includes(permission)),
   hasAnyPermission: vi.fn(() => true),
-  hasAllPermissions: vi.fn(() => true),
   canModify: vi.fn(() => true),
 };
 

+ 0 - 2
frontend/src/__tests__/components/SpoolmanSettings.test.tsx

@@ -23,7 +23,6 @@ vi.mock('../../api/client', () => ({
     updateSpoolmanSettings: vi.fn(),
     getSpoolmanStatus: vi.fn(),
     connectSpoolman: vi.fn(),
-    disconnectSpoolman: vi.fn(),
     syncAllPrintersAms: vi.fn(),
     syncPrinterAms: vi.fn(),
     syncSpoolmanAmsWeights: vi.fn(),
@@ -62,7 +61,6 @@ describe('SpoolmanSettings', () => {
     });
     vi.mocked(api.getPrinters).mockResolvedValue([]);
     vi.mocked(api.connectSpoolman).mockResolvedValue({ success: true, message: 'Connected' });
-    vi.mocked(api.disconnectSpoolman).mockResolvedValue({ success: true, message: 'Disconnected' });
     vi.mocked(api.syncAllPrintersAms).mockResolvedValue({
       success: true,
       synced_count: 3,

+ 0 - 110
frontend/src/__tests__/components/TagDetectedModal.test.tsx

@@ -1,110 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, fireEvent } from '@testing-library/react';
-import { render } from '../utils';
-import { TagDetectedModal } from '../../components/spoolbuddy/TagDetectedModal';
-import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
-
-const mockUpdateSpoolWeight = vi.fn();
-
-vi.mock('../../api/client', () => ({
-  api: {
-    getSettings: vi.fn().mockResolvedValue({}),
-    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
-  },
-  spoolbuddyApi: {
-    updateSpoolWeight: (...args: unknown[]) => mockUpdateSpoolWeight(...args),
-  },
-}));
-
-const mockSpool: MatchedSpool = {
-  id: 7,
-  tag_uid: 'AA11BB22CC33DD44',
-  material: 'PETG',
-  subtype: 'HF',
-  color_name: 'Orange',
-  rgba: 'FF6600FF',
-  brand: 'Overture',
-  label_weight: 1000,
-  core_weight: 250,
-  weight_used: 100,
-};
-
-const defaultProps = {
-  isOpen: true,
-  onClose: vi.fn(),
-  spool: mockSpool,
-  tagUid: 'AA11BB22CC33DD44',
-  scaleWeight: 950.0,
-  weightStable: true,
-  onSyncWeight: vi.fn(),
-  onAssignToAms: vi.fn(),
-  onLinkSpool: vi.fn(),
-  onAddToInventory: vi.fn(),
-};
-
-describe('TagDetectedModal', () => {
-  beforeEach(() => {
-    vi.clearAllMocks();
-    mockUpdateSpoolWeight.mockResolvedValue({ status: 'ok', weight_used: 300 });
-  });
-
-  it('does not render when isOpen=false', () => {
-    render(<TagDetectedModal {...defaultProps} isOpen={false} />);
-    expect(screen.queryByText('Spool Detected')).not.toBeInTheDocument();
-  });
-
-  it('renders known spool view when spool provided', () => {
-    render(<TagDetectedModal {...defaultProps} />);
-
-    expect(screen.getByText('Spool Detected')).toBeInTheDocument();
-    expect(screen.getByText('Orange')).toBeInTheDocument();
-    expect(screen.getByText(/Overture/)).toBeInTheDocument();
-    expect(screen.getByText(/PETG/)).toBeInTheDocument();
-  });
-
-  it('renders unknown tag view when spool is null', () => {
-    render(
-      <TagDetectedModal
-        {...defaultProps}
-        spool={null}
-        tagUid="DEADBEEF11223344"
-      />
-    );
-
-    expect(screen.getByText('New Tag Detected')).toBeInTheDocument();
-    expect(screen.getByText('DEADBEEF11223344')).toBeInTheDocument();
-  });
-
-  it('closes on Escape key', () => {
-    const onClose = vi.fn();
-    render(<TagDetectedModal {...defaultProps} onClose={onClose} />);
-
-    fireEvent.keyDown(document, { key: 'Escape' });
-    expect(onClose).toHaveBeenCalledTimes(1);
-  });
-
-  it('shows weight from scale', () => {
-    // scaleWeight=950g, core=250g → remaining = 950-250 = 700g
-    render(<TagDetectedModal {...defaultProps} scaleWeight={950} />);
-
-    expect(screen.getByText('700g')).toBeInTheDocument();
-  });
-
-  it('shows action buttons (Assign to AMS, Sync Weight)', () => {
-    const onAssign = vi.fn();
-    const onSync = vi.fn();
-    render(
-      <TagDetectedModal
-        {...defaultProps}
-        onAssignToAms={onAssign}
-        onSyncWeight={onSync}
-      />
-    );
-
-    expect(screen.getByText('Assign to AMS')).toBeInTheDocument();
-    expect(screen.getByText('Sync Weight')).toBeInTheDocument();
-
-    fireEvent.click(screen.getByText('Assign to AMS'));
-    expect(onAssign).toHaveBeenCalledTimes(1);
-  });
-});

+ 155 - 0
frontend/src/__tests__/components/TimelapseEditorModalJob.test.tsx

@@ -0,0 +1,155 @@
+/**
+ * The timelapse editor's Save starts a re-encode job on the server; the editor
+ * shows its progress, can cancel it, and reports how it ended.
+ */
+import { useState } from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { TimelapseEditorModal } from '../../components/TimelapseEditorModal';
+import { api } from '../../api/client';
+import type { TimelapseJob } from '../../api/client';
+
+vi.mock('../../api/client', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../api/client')>();
+  return {
+    ...actual,
+    api: {
+      ...actual.api,
+      getTimelapseInfo: vi.fn(),
+      getTimelapseThumbnails: vi.fn(),
+      getTimelapseJob: vi.fn(),
+      processTimelapse: vi.fn(),
+      cancelTimelapseJob: vi.fn(),
+    },
+  };
+});
+
+const job = (overrides: Partial<TimelapseJob>): TimelapseJob => ({
+  status: 'running',
+  progress: null,
+  output_path: null,
+  message: null,
+  error: null,
+  ...overrides,
+});
+
+function open() {
+  const onClose = vi.fn();
+  const onSave = vi.fn();
+  render(<TimelapseEditorModal archiveId={5} timelapseSrc="/t.mp4" onClose={onClose} onSave={onSave} />);
+  return { onClose, onSave };
+}
+
+const POLL = { timeout: 3000 };
+
+describe('TimelapseEditorModal re-encode job', () => {
+  beforeEach(() => {
+    vi.mocked(api.getTimelapseInfo).mockResolvedValue({
+      duration: 10,
+      width: 320,
+      height: 240,
+      fps: 30,
+      codec: 'h264',
+      file_size: 1000,
+      has_audio: false,
+    });
+    vi.mocked(api.getTimelapseThumbnails).mockResolvedValue({ thumbnails: [], timestamps: [] });
+    vi.mocked(api.getTimelapseJob).mockReset().mockResolvedValue(null);
+    vi.mocked(api.processTimelapse).mockReset().mockResolvedValue(job({}));
+    vi.mocked(api.cancelTimelapseJob).mockReset().mockResolvedValue(job({ status: 'cancelled' }));
+  });
+
+  it('Save starts the job, shows its progress and closes with the result', async () => {
+    vi.mocked(api.getTimelapseJob)
+      .mockResolvedValueOnce(null)
+      .mockResolvedValueOnce(job({ progress: 0.4 }))
+      .mockResolvedValue(job({ status: 'completed', progress: 1, message: 'Timelapse replaced successfully' }));
+    const { onClose, onSave } = open();
+
+    await userEvent.click(await screen.findByRole('button', { name: /save/i }));
+
+    expect(api.processTimelapse).toHaveBeenCalledWith(
+      5,
+      expect.objectContaining({ saveMode: 'replace', audioVolume: 0.8 }),
+      undefined
+    );
+    expect(await screen.findByText('Processing… 40%', {}, POLL)).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /processing/i })).toBeDisabled();
+    await waitFor(() => expect(onClose).toHaveBeenCalled(), POLL);
+    expect(onSave).toHaveBeenCalled();
+    expect(screen.getByText('Timelapse replaced successfully')).toBeInTheDocument();
+  });
+
+  it('Cancel stops the job and keeps the editor open', async () => {
+    vi.mocked(api.getTimelapseJob).mockResolvedValue(job({ progress: 0.2 }));
+    const { onClose, onSave } = open();
+
+    await userEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
+
+    expect(api.cancelTimelapseJob).toHaveBeenCalledWith(5);
+    expect(await screen.findByText('Processing cancelled')).toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
+    expect(onClose).not.toHaveBeenCalled();
+    expect(onSave).not.toHaveBeenCalled();
+  });
+
+  it('a failed job says why and keeps the editor open', async () => {
+    vi.mocked(api.getTimelapseJob)
+      .mockResolvedValueOnce(job({}))
+      .mockResolvedValue(job({ status: 'failed', error: 'Video processing failed' }));
+    const { onClose } = open();
+
+    expect(await screen.findByText('Video processing failed', {}, POLL)).toBeInTheDocument();
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('a reopened editor shows a job still running', async () => {
+    vi.mocked(api.getTimelapseJob).mockResolvedValue(job({ progress: 0.75 }));
+    open();
+
+    expect(await screen.findByText('Processing… 75%')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /processing/i })).toBeDisabled();
+    expect(api.processTimelapse).not.toHaveBeenCalled();
+  });
+
+  it('an earlier finished job is not reported again', async () => {
+    vi.mocked(api.getTimelapseJob).mockResolvedValue(job({ status: 'completed', message: 'Saved as old.mp4' }));
+    const { onClose } = open();
+
+    await screen.findByRole('button', { name: /save/i });
+    await waitFor(() => expect(api.getTimelapseJob).toHaveBeenCalled());
+    expect(screen.queryByText('Saved as old.mp4')).not.toBeInTheDocument();
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('reopened after the job ended meanwhile, the editor stays open and quiet', async () => {
+    vi.mocked(api.getTimelapseJob).mockResolvedValue(job({ progress: 0.5 }));
+    const onClose = vi.fn();
+    // Keeps the cache across the reopening, like the app's client (the test
+    // client drops it at once).
+    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    function Viewer() {
+      const [editing, setEditing] = useState(true);
+      return (
+        <QueryClientProvider client={client}>
+          <button onClick={() => setEditing((e) => !e)}>toggle editor</button>
+          {editing && <TimelapseEditorModal archiveId={5} timelapseSrc="/t.mp4" onClose={onClose} />}
+        </QueryClientProvider>
+      );
+    }
+    render(<Viewer />);
+    expect(await screen.findByText('Processing… 50%')).toBeInTheDocument();
+
+    await userEvent.click(screen.getByRole('button', { name: 'toggle editor' }));
+    vi.mocked(api.getTimelapseJob).mockResolvedValue(job({ status: 'completed', message: 'Timelapse replaced successfully' }));
+    await userEvent.click(screen.getByRole('button', { name: 'toggle editor' }));
+
+    await waitFor(() => expect(screen.getByRole('button', { name: /save/i })).toBeEnabled());
+    expect(onClose).not.toHaveBeenCalled();
+    expect(screen.queryByText('Timelapse replaced successfully')).not.toBeInTheDocument();
+  });
+});

+ 0 - 680
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -1,680 +0,0 @@
-/**
- * Tests for the VirtualPrinterSettings component.
- *
- * Tests the virtual printer configuration UI including:
- * - Enable/disable toggle
- * - Access code management
- * - Archive mode selection
- * - Status display
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../utils';
-import { VirtualPrinterSettings } from '../../components/VirtualPrinterSettings';
-
-// Mock the API client
-vi.mock('../../api/client', () => ({
-  api: {
-    getSettings: vi.fn().mockResolvedValue({}),
-    updateSettings: vi.fn().mockResolvedValue({}),
-    getPrinters: vi.fn().mockResolvedValue([]),
-    getNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }),
-  },
-  virtualPrinterApi: {
-    getSettings: vi.fn(),
-    updateSettings: vi.fn(),
-    getModels: vi.fn().mockResolvedValue({
-      models: {
-        'BL-P001': 'X1C',
-        'C12': 'P1S',
-        'N7': 'P2S',
-      },
-    }),
-  },
-}));
-
-// Import mocked module
-import { virtualPrinterApi } from '../../api/client';
-
-// Mock data factory
-const createMockSettings = (overrides = {}) => ({
-  enabled: false,
-  access_code_set: false,
-  mode: 'archive' as const,
-  model: 'BL-P001',
-  target_printer_id: null as number | null,
-  remote_interface_ip: null as string | null,
-  status: {
-    enabled: false,
-    running: false,
-    mode: 'archive',
-    name: 'Bambuddy',
-    serial: '00M00A391800001',
-    model: 'BL-P001',
-    model_name: 'X1C',
-    pending_files: 0,
-  },
-  ...overrides,
-});
-
-describe('VirtualPrinterSettings', () => {
-  beforeEach(() => {
-    vi.clearAllMocks();
-    // Default mock implementation
-    vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(createMockSettings());
-    vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(createMockSettings());
-  });
-
-  describe('rendering', () => {
-    it('renders loading state initially', () => {
-      // Delay the API response to catch loading state
-      vi.mocked(virtualPrinterApi.getSettings).mockImplementation(
-        () => new Promise(() => {}) // Never resolves
-      );
-      render(<VirtualPrinterSettings />);
-
-      // Should show loading spinner
-      expect(document.querySelector('.animate-spin')).toBeInTheDocument();
-    });
-
-    it('renders component title', async () => {
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Virtual Printer')).toBeInTheDocument();
-      });
-    });
-
-    it('renders enable toggle', async () => {
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
-      });
-    });
-
-    it('renders access code section', async () => {
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Access Code')).toBeInTheDocument();
-      });
-    });
-
-    it('renders mode section', async () => {
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Mode')).toBeInTheDocument();
-      });
-    });
-
-    it('renders how it works info', async () => {
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('How it works:')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('status indicator', () => {
-    it('shows Stopped when not running', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ status: { ...createMockSettings().status, running: false } })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Stopped')).toBeInTheDocument();
-      });
-    });
-
-    it('shows Running when active', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({
-          enabled: true,
-          status: { ...createMockSettings().status, enabled: true, running: true },
-        })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Running')).toBeInTheDocument();
-      });
-    });
-
-    it('shows status details when running', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({
-          enabled: true,
-          status: {
-            enabled: true,
-            running: true,
-            mode: 'archive',
-            name: 'Bambuddy',
-            serial: '00M00A391800001',
-            model: 'BL-P001',
-            model_name: 'X1C',
-            pending_files: 0,
-          },
-        })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Status Details')).toBeInTheDocument();
-        expect(screen.getByText('Bambuddy')).toBeInTheDocument();
-        expect(screen.getByText('00M00A391800001')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('access code', () => {
-    it('shows warning when access code not set', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ access_code_set: false })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('No access code set - required to enable')).toBeInTheDocument();
-      });
-    });
-
-    it('shows success when access code is set', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ access_code_set: true })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Access code is set')).toBeInTheDocument();
-      });
-    });
-
-    it('shows character count while typing', async () => {
-      const user = userEvent.setup();
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Access Code')).toBeInTheDocument();
-      });
-
-      const input = screen.getByPlaceholderText('Enter 8-char code');
-      await user.type(input, '1234');
-
-      expect(screen.getByText('(4/8)')).toBeInTheDocument();
-    });
-
-    it('saves access code on button click', async () => {
-      const user = userEvent.setup();
-      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
-        createMockSettings({ access_code_set: true })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Access Code')).toBeInTheDocument();
-      });
-
-      const input = screen.getByPlaceholderText('Enter 8-char code');
-      await user.type(input, '12345678');
-
-      const saveButton = screen.getByRole('button', { name: 'Save' });
-      await user.click(saveButton);
-
-      await waitFor(() => {
-        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({
-          access_code: '12345678',
-        });
-      });
-    });
-
-    it('toggles password visibility', async () => {
-      const user = userEvent.setup();
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Access Code')).toBeInTheDocument();
-      });
-
-      const input = screen.getByPlaceholderText('Enter 8-char code');
-      expect(input).toHaveAttribute('type', 'password');
-
-      // Find and click the visibility toggle (eye icon button)
-      const toggleButtons = screen.getAllByRole('button');
-      const visibilityToggle = toggleButtons.find(
-        (btn) => btn.querySelector('svg') && btn.className.includes('absolute')
-      );
-
-      if (visibilityToggle) {
-        await user.click(visibilityToggle);
-        expect(input).toHaveAttribute('type', 'text');
-      }
-    });
-  });
-
-  describe('mode selection', () => {
-    it('renders Archive mode option', async () => {
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Archive')).toBeInTheDocument();
-        expect(screen.getByText('Archive files immediately')).toBeInTheDocument();
-      });
-    });
-
-    it('renders Review mode option', async () => {
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Review')).toBeInTheDocument();
-        expect(screen.getByText('Review before archiving')).toBeInTheDocument();
-      });
-    });
-
-    it('renders Queue mode option', async () => {
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Queue')).toBeInTheDocument();
-        expect(screen.getByText('Archive and add to queue')).toBeInTheDocument();
-      });
-    });
-
-    it('highlights current mode (review)', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ mode: 'review' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        const reviewButton = screen.getByText('Review').closest('button');
-        expect(reviewButton?.className).toContain('border-bambu-green');
-      });
-    });
-
-    it('highlights current mode (legacy immediate maps to archive button)', async () => {
-      // Pre-#1429 the wire value was `immediate` but the UI button was
-      // labeled "Archive". Backend migration rewrites stored rows, but a
-      // stale-cached settings payload may still carry the legacy value —
-      // the component normalises it client-side so the right button lights up.
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ mode: 'immediate' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        const archiveButton = screen.getByText('Archive').closest('button');
-        expect(archiveButton?.className).toContain('border-bambu-green');
-      });
-    });
-
-    it('highlights current mode (legacy print_queue maps to queue button)', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ mode: 'print_queue' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        const queueButton = screen.getByText('Queue').closest('button');
-        expect(queueButton?.className).toContain('border-bambu-green');
-      });
-    });
-
-    it('changes mode to review on click', async () => {
-      const user = userEvent.setup();
-      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
-        createMockSettings({ mode: 'review' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Review')).toBeInTheDocument();
-      });
-
-      const reviewButton = screen.getByText('Review').closest('button');
-      if (reviewButton) {
-        await user.click(reviewButton);
-      }
-
-      await waitFor(() => {
-        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'review' });
-      });
-    });
-
-    it('changes mode to queue on click', async () => {
-      const user = userEvent.setup();
-      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
-        createMockSettings({ mode: 'queue' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Queue')).toBeInTheDocument();
-      });
-
-      const queueButton = screen.getByText('Queue').closest('button');
-      if (queueButton) {
-        await user.click(queueButton);
-      }
-
-      await waitFor(() => {
-        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'queue' });
-      });
-    });
-  });
-
-  describe('enable/disable toggle', () => {
-    it('cannot enable without access code', async () => {
-      const user = userEvent.setup();
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: false, access_code_set: false })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
-      });
-
-      // Find the toggle switch (it's a button with relative class containing the slider)
-      const allButtons = screen.getAllByRole('button');
-      const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));
-
-      if (toggle) {
-        await user.click(toggle);
-      }
-
-      // Should not call update API (no access code set)
-      expect(virtualPrinterApi.updateSettings).not.toHaveBeenCalled();
-    });
-
-    it('can enable when access code is set', async () => {
-      const user = userEvent.setup();
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: false, access_code_set: true })
-      );
-      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, access_code_set: true })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
-      });
-
-      // Find the toggle switch (it's a button with rounded-full and w-12 classes)
-      const allButtons = screen.getAllByRole('button');
-      const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));
-
-      expect(toggle).toBeDefined();
-      if (toggle) {
-        await user.click(toggle);
-      }
-
-      await waitFor(() => {
-        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith(
-          expect.objectContaining({ enabled: true })
-        );
-      });
-    });
-
-    it('can disable when enabled', async () => {
-      const user = userEvent.setup();
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, access_code_set: true })
-      );
-      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
-        createMockSettings({ enabled: false, access_code_set: true })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
-      });
-
-      // Find the toggle switch
-      const allButtons = screen.getAllByRole('button');
-      const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));
-
-      expect(toggle).toBeDefined();
-      if (toggle) {
-        await user.click(toggle);
-      }
-
-      await waitFor(() => {
-        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith(
-          expect.objectContaining({ enabled: false })
-        );
-      });
-    });
-  });
-
-  describe('info section', () => {
-    it('shows setup required warning', async () => {
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Setup Required')).toBeInTheDocument();
-      });
-    });
-
-    it('shows link to setup guide', async () => {
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Read the setup guide before enabling')).toBeInTheDocument();
-      });
-    });
-
-    it('shows how it works section', async () => {
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('How it works:')).toBeInTheDocument();
-        expect(screen.getByText(/virtual printers appear in your slicer/)).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('proxy mode', () => {
-    it('renders Proxy mode option', async () => {
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Proxy')).toBeInTheDocument();
-        expect(screen.getByText('Relay to real printer')).toBeInTheDocument();
-      });
-    });
-
-    it('highlights proxy mode when selected', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ mode: 'proxy', target_printer_id: 1 })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        const proxyButton = screen.getByText('Proxy').closest('button');
-        expect(proxyButton?.className).toContain('border-blue-500');
-      });
-    });
-
-    it('shows proxy status details when running in proxy mode', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({
-          enabled: true,
-          mode: 'proxy',
-          target_printer_id: 1,
-          status: {
-            enabled: true,
-            running: true,
-            mode: 'proxy',
-            name: 'Bambuddy (Proxy)',
-            serial: '00M00A391800001',
-            model: 'BL-P001',
-            model_name: 'X1C',
-            pending_files: 0,
-            proxy: {
-              running: true,
-              target_host: '192.168.1.100',
-              ftp_port: 990,  // Privileged port for Bambu Studio compatibility
-              mqtt_port: 8883,
-              ftp_connections: 1,
-              mqtt_connections: 2,
-            },
-          },
-        })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Running')).toBeInTheDocument();
-        expect(screen.getByText('Status Details')).toBeInTheDocument();
-        // IP address appears in multiple places (target printer and status details)
-        expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0);
-      });
-    });
-
-    it('shows target printer dropdown in proxy mode', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ mode: 'proxy' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Target Printer')).toBeInTheDocument();
-        expect(screen.getByText('Select a printer...')).toBeInTheDocument();
-      });
-    });
-
-    it('changes mode to proxy on click', async () => {
-      const user = userEvent.setup();
-      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
-        createMockSettings({ mode: 'proxy' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Proxy')).toBeInTheDocument();
-      });
-
-      const proxyButton = screen.getByText('Proxy').closest('button');
-      if (proxyButton) {
-        await user.click(proxyButton);
-      }
-
-      await waitFor(() => {
-        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'proxy' });
-      });
-    });
-  });
-
-  describe('network interface override', () => {
-    it('shows interface dropdown when enabled in immediate mode', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'archive' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Network Interface Override')).toBeInTheDocument();
-      });
-    });
-
-    it('shows interface dropdown when enabled in review mode', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'review' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Network Interface Override')).toBeInTheDocument();
-      });
-    });
-
-    it('shows interface dropdown when enabled in print_queue mode', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'queue' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Network Interface Override')).toBeInTheDocument();
-      });
-    });
-
-    it('shows interface dropdown when enabled in proxy mode', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'proxy', target_printer_id: 1 })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Network Interface Override')).toBeInTheDocument();
-      });
-    });
-
-    it('hides interface dropdown when disabled', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: false, mode: 'archive' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Mode')).toBeInTheDocument();
-      });
-
-      expect(screen.queryByText('Network Interface Override')).not.toBeInTheDocument();
-    });
-
-    it('shows configured status when interface is set', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'archive', remote_interface_ip: '10.0.0.50' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Interface override active')).toBeInTheDocument();
-      });
-    });
-
-    it('shows optional hint when no interface is set', async () => {
-      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'archive', remote_interface_ip: '' })
-      );
-
-      render(<VirtualPrinterSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText(/Optional.*auto-detected IP/)).toBeInTheDocument();
-      });
-    });
-  });
-});

+ 0 - 80
frontend/src/__tests__/components/WeightDisplay.test.tsx

@@ -1,80 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, fireEvent, waitFor } from '@testing-library/react';
-import { render } from '../utils';
-import { WeightDisplay } from '../../components/spoolbuddy/WeightDisplay';
-
-const mockTare = vi.fn();
-
-vi.mock('../../api/client', () => ({
-  api: {
-    getSettings: vi.fn().mockResolvedValue({}),
-    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
-  },
-  spoolbuddyApi: {
-    tare: (...args: unknown[]) => mockTare(...args),
-  },
-}));
-
-const defaultProps = {
-  weight: 823.4,
-  weightStable: true,
-  deviceOnline: true,
-  deviceId: 'sb-0001',
-};
-
-describe('WeightDisplay', () => {
-  beforeEach(() => {
-    vi.clearAllMocks();
-    mockTare.mockResolvedValue({ status: 'ok' });
-  });
-
-  it('renders weight value with 1 decimal place', () => {
-    render(<WeightDisplay {...defaultProps} weight={823.456} />);
-    expect(screen.getByText('823.5')).toBeInTheDocument();
-  });
-
-  it('shows green dot when stable and online', () => {
-    const { container } = render(
-      <WeightDisplay {...defaultProps} weightStable={true} deviceOnline={true} />
-    );
-    const dot = container.querySelector('.bg-green-500');
-    expect(dot).toBeInTheDocument();
-    expect(screen.getByText('Stable')).toBeInTheDocument();
-  });
-
-  it('shows amber dot when unstable', () => {
-    const { container } = render(
-      <WeightDisplay {...defaultProps} weightStable={false} deviceOnline={true} />
-    );
-    const dot = container.querySelector('.bg-amber-500');
-    expect(dot).toBeInTheDocument();
-    expect(screen.getByText('Measuring...')).toBeInTheDocument();
-  });
-
-  it('shows gray dot when offline', () => {
-    const { container } = render(
-      <WeightDisplay {...defaultProps} deviceOnline={false} />
-    );
-    const dot = container.querySelector('.bg-zinc-600');
-    expect(dot).toBeInTheDocument();
-    expect(screen.getByText('No reading')).toBeInTheDocument();
-  });
-
-  it('tare button calls spoolbuddyApi.tare(deviceId)', async () => {
-    render(<WeightDisplay {...defaultProps} />);
-
-    const tareButton = screen.getByText('Tare');
-    fireEvent.click(tareButton);
-
-    await waitFor(() => {
-      expect(mockTare).toHaveBeenCalledWith('sb-0001');
-    });
-  });
-
-  it('tare button is disabled when no deviceId', () => {
-    render(<WeightDisplay {...defaultProps} deviceId={null} />);
-
-    const tareButton = screen.getByText('Tare');
-    expect(tareButton).toBeDisabled();
-  });
-});

+ 0 - 1
frontend/src/__tests__/components/spool-form/PAProfileSectionNozzle.test.tsx

@@ -35,7 +35,6 @@ describe('PAProfileSection nozzle-specific profiles (#2618)', () => {
       <I18nextProvider i18n={i18n}>
         <PAProfileSection
           formData={{ ...defaultFormData, material: 'PAHT-CF', slicer_filament: 'GFN05' }}
-          updateField={vi.fn()}
           printersWithCalibrations={printers}
           selectedProfiles={new Set()}
           setSelectedProfiles={vi.fn()}

+ 7 - 23
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyColorName3090.test.tsx

@@ -2,7 +2,7 @@
  * The kiosk shows the colour the rest of Bambuddy shows (#3090).
  *
  * Reported by @Sawtaytoes: scan a Bambu RFID spool that Bambuddy's inventory
- * calls "Candy Red" and SpoolBuddy's dialog says "Unknown color" — with the
+ * calls "Candy Red" and SpoolBuddy says "Unknown color" — with the
  * right red swatch beside it. The name was never in the spool record. Bambu's tags frequently carry none, so Bambuddy resolves the swatch's
  * own hex against the colour catalog; the kiosk was printing the empty column.
  *
@@ -14,7 +14,6 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { screen } from '@testing-library/react';
 import { render } from '../../utils';
-import { TagDetectedModal } from '../../../components/spoolbuddy/TagDetectedModal';
 import { SpoolInfoCard } from '../../../components/spoolbuddy/SpoolInfoCard';
 import type { MatchedSpool } from '../../../hooks/useSpoolBuddyState';
 import { setColorCatalog, __resetColorCatalogForTests } from '../../../utils/colors';
@@ -44,17 +43,10 @@ const candyRed: MatchedSpool = {
   weight_used: 0,
 };
 
-const modalProps = {
-  isOpen: true,
-  onClose: vi.fn(),
+const cardProps = {
   spool: candyRed,
-  tagUid: '00000100',
   scaleWeight: 1000,
-  weightStable: true,
   onSyncWeight: vi.fn(),
-  onAssignToAms: vi.fn(),
-  onLinkSpool: vi.fn(),
-  onAddToInventory: vi.fn(),
 };
 
 describe('SpoolBuddy colour names (#3090)', () => {
@@ -64,26 +56,18 @@ describe('SpoolBuddy colour names (#3090)', () => {
   });
 
   it('names the scanned spool from the catalog when the tag carried no name', () => {
-    render(<TagDetectedModal {...modalProps} />);
+    render(<SpoolInfoCard {...cardProps} />);
 
     expect(screen.getByText('Candy Red')).toBeInTheDocument();
     expect(screen.queryByText('Unknown color')).not.toBeInTheDocument();
   });
 
-  it('names it on the dashboard card too, not only in the scan dialog', () => {
-    // Four components render a scanned spool. Fixing the dialog alone leaves
-    // the same spool nameless on the card behind it.
-    render(<SpoolInfoCard spool={candyRed} scaleWeight={1000} weightStable onSyncWeight={vi.fn()} />);
-
-    expect(screen.getByText('Candy Red')).toBeInTheDocument();
-  });
-
   it('prefers the catalog over a name Spoolman never stored', () => {
     // Spoolman has no colour-name field, so the backend sends the subtype with
     // color_name_is_synthesized set. "Silk+" is not a colour.
     render(
-      <TagDetectedModal
-        {...modalProps}
+      <SpoolInfoCard
+        {...cardProps}
         spool={{ ...candyRed, color_name: 'Silk+', color_name_is_synthesized: true }}
       />,
     );
@@ -96,7 +80,7 @@ describe('SpoolBuddy colour names (#3090)', () => {
     // The catalog is the fallback, not an override — a user who typed
     // "My Favourite Red" sees it, whatever the hex says.
     render(
-      <TagDetectedModal {...modalProps} spool={{ ...candyRed, color_name: 'My Favourite Red' }} />,
+      <SpoolInfoCard {...cardProps} spool={{ ...candyRed, color_name: 'My Favourite Red' }} />,
     );
 
     expect(screen.getByText('My Favourite Red')).toBeInTheDocument();
@@ -107,7 +91,7 @@ describe('SpoolBuddy colour names (#3090)', () => {
     // Unknown hex, no stored name: the string has to come from i18n, because
     // the kiosk is used in every language Bambuddy ships.
     render(
-      <TagDetectedModal {...modalProps} spool={{ ...candyRed, rgba: '123456FF' }} />,
+      <SpoolInfoCard {...cardProps} spool={{ ...candyRed, rgba: '123456FF' }} />,
     );
 
     expect(screen.getByText('Unknown color')).toBeInTheDocument();

+ 27 - 45
frontend/src/__tests__/contexts/AuthContext.test.tsx

@@ -147,42 +147,42 @@ describe('AuthContext', () => {
         result.current.hasAnyPermission('printers:read' as Permission, 'settings:update' as Permission)
       ).toBe(true);
     });
+  });
 
-    it('hasAllPermissions returns true for any permissions when auth disabled', async () => {
-      const { result } = renderHook(() => useAuth(), {
-        wrapper: createWrapper(),
-      });
-
-      await waitFor(() => {
-        expect(result.current.authEnabled).toBe(false);
-      });
+  describe('first-run setup', () => {
+    const realLocation = window.location;
 
-      expect(
-        result.current.hasAllPermissions('printers:read' as Permission, 'settings:update' as Permission)
-      ).toBe(true);
-    });
-  });
+    function stubLocation(pathname: string) {
+      const { origin, protocol, host, hostname, port } = realLocation;
+      const location = { origin, protocol, host, hostname, port, pathname, href: origin + pathname, search: '', hash: '' };
+      Object.defineProperty(window, 'location', { configurable: true, value: location });
+      return location;
+    }
 
-  describe('when auth requires setup', () => {
     beforeEach(() => {
       server.use(
-        http.get('/api/v1/auth/status', () => {
-          return HttpResponse.json({
-            auth_enabled: false,
-            requires_setup: true,
-          });
-        })
+        http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: false, requires_setup: true }))
       );
     });
 
-    it('requiresSetup is true', async () => {
-      const { result } = renderHook(() => useAuth(), {
-        wrapper: createWrapper(),
-      });
+    afterEach(() => {
+      Object.defineProperty(window, 'location', { configurable: true, value: realLocation });
+    });
 
-      await waitFor(() => {
-        expect(result.current.requiresSetup).toBe(true);
-      });
+    it('sends a new install to the setup page', async () => {
+      const location = stubLocation('/');
+
+      renderHook(() => useAuth(), { wrapper: createWrapper() });
+
+      await waitFor(() => expect(location.href).toBe('/setup'));
+    });
+
+    it('leaves a camera page where it is', async () => {
+      const location = stubLocation('/camera/1');
+      const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
+
+      await waitFor(() => expect(result.current.loading).toBe(false));
+      expect(location.href).toBe(`${realLocation.origin}/camera/1`);
     });
   });
 
@@ -303,24 +303,6 @@ describe('AuthContext', () => {
         )
       ).toBe(true);
     });
-
-    it('hasAllPermissions returns true for any combination', async () => {
-      const { result } = renderHook(() => useAuth(), {
-        wrapper: createWrapper(),
-      });
-
-      await waitFor(() => {
-        expect(result.current.loading).toBe(false);
-      });
-
-      expect(
-        result.current.hasAllPermissions(
-          'settings:update' as Permission,
-          'api_keys:create' as Permission,
-          'groups:delete' as Permission
-        )
-      ).toBe(true);
-    });
   });
 
   describe('auth:expired event (#1698)', () => {

+ 0 - 161
frontend/src/__tests__/hooks/useLongPress.test.ts

@@ -1,161 +0,0 @@
-/**
- * Tests for the useLongPress hook.
- */
-
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { renderHook, act } from '@testing-library/react';
-import { useLongPress } from '../../hooks/useLongPress';
-
-describe('useLongPress', () => {
-  beforeEach(() => {
-    vi.useFakeTimers();
-  });
-
-  afterEach(() => {
-    vi.useRealTimers();
-  });
-
-  it('calls onLongPress after delay', () => {
-    const onLongPress = vi.fn();
-    const onClick = vi.fn();
-
-    const { result } = renderHook(() =>
-      useLongPress({ onLongPress, onClick, delay: 500 })
-    );
-
-    // Simulate mouse down
-    act(() => {
-      result.current.onMouseDown({} as React.MouseEvent);
-    });
-
-    // Fast forward past the delay
-    act(() => {
-      vi.advanceTimersByTime(600);
-    });
-
-    // Should trigger long press
-    expect(onLongPress).toHaveBeenCalled();
-    expect(onClick).not.toHaveBeenCalled();
-  });
-
-  it('calls onClick for short press', () => {
-    const onLongPress = vi.fn();
-    const onClick = vi.fn();
-
-    const { result } = renderHook(() =>
-      useLongPress({ onLongPress, onClick, delay: 500 })
-    );
-
-    // Simulate mouse down
-    act(() => {
-      result.current.onMouseDown({} as React.MouseEvent);
-    });
-
-    // Release before delay
-    act(() => {
-      vi.advanceTimersByTime(200);
-      result.current.onMouseUp({} as React.MouseEvent);
-    });
-
-    // Should trigger click, not long press
-    expect(onClick).toHaveBeenCalled();
-    expect(onLongPress).not.toHaveBeenCalled();
-  });
-
-  it('cancels on mouse leave', () => {
-    const onLongPress = vi.fn();
-    const onClick = vi.fn();
-
-    const { result } = renderHook(() =>
-      useLongPress({ onLongPress, onClick, delay: 500 })
-    );
-
-    // Simulate mouse down
-    act(() => {
-      result.current.onMouseDown({} as React.MouseEvent);
-    });
-
-    // Mouse leaves before delay
-    act(() => {
-      vi.advanceTimersByTime(200);
-      result.current.onMouseLeave({} as React.MouseEvent);
-    });
-
-    // Continue past delay
-    act(() => {
-      vi.advanceTimersByTime(400);
-    });
-
-    // Neither should be called
-    expect(onLongPress).not.toHaveBeenCalled();
-    expect(onClick).not.toHaveBeenCalled();
-  });
-
-  it('uses default delay of 500ms', () => {
-    const onLongPress = vi.fn();
-
-    const { result } = renderHook(() =>
-      useLongPress({ onLongPress })
-    );
-
-    // Simulate mouse down
-    act(() => {
-      result.current.onMouseDown({} as React.MouseEvent);
-    });
-
-    // Just before default delay
-    act(() => {
-      vi.advanceTimersByTime(450);
-    });
-    expect(onLongPress).not.toHaveBeenCalled();
-
-    // After default delay
-    act(() => {
-      vi.advanceTimersByTime(100);
-    });
-    expect(onLongPress).toHaveBeenCalled();
-  });
-
-  it('handles touch events', () => {
-    const onLongPress = vi.fn();
-
-    const { result } = renderHook(() =>
-      useLongPress({ onLongPress, delay: 500 })
-    );
-
-    // Simulate touch start
-    act(() => {
-      result.current.onTouchStart({} as React.TouchEvent);
-    });
-
-    // Fast forward past the delay
-    act(() => {
-      vi.advanceTimersByTime(600);
-    });
-
-    expect(onLongPress).toHaveBeenCalled();
-  });
-
-  it('cancels on touch end', () => {
-    const onLongPress = vi.fn();
-    const onClick = vi.fn();
-
-    const { result } = renderHook(() =>
-      useLongPress({ onLongPress, onClick, delay: 500 })
-    );
-
-    // Simulate touch start
-    act(() => {
-      result.current.onTouchStart({} as React.TouchEvent);
-    });
-
-    // End touch before delay
-    act(() => {
-      vi.advanceTimersByTime(200);
-      result.current.onTouchEnd({} as React.TouchEvent);
-    });
-
-    expect(onClick).toHaveBeenCalled();
-    expect(onLongPress).not.toHaveBeenCalled();
-  });
-});

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