Parcourir la source

Add printer video downloads and range selection (#2853)

Sean K il y a 2 semaines
Parent
commit
55cc64c87d
43 fichiers modifiés avec 4046 ajouts et 184 suppressions
  1. 1 0
      CHANGELOG.md
  2. 208 1
      backend/app/api/routes/archives.py
  3. 172 42
      backend/app/api/routes/printers.py
  4. 103 0
      backend/app/core/auth.py
  5. 17 0
      backend/app/main.py
  6. 25 1
      backend/app/schemas/printer.py
  7. 170 10
      backend/app/services/bambu_ftp.py
  8. 719 0
      backend/app/services/printer_media.py
  9. 36 0
      backend/app/utils/http.py
  10. 9 7
      backend/tests/conftest.py
  11. 458 0
      backend/tests/integration/test_archives_api.py
  12. 232 2
      backend/tests/integration/test_printers_api.py
  13. 123 6
      backend/tests/unit/services/test_bambu_ftp.py
  14. 17 1
      backend/tests/unit/test_http_utils.py
  15. 411 0
      backend/tests/unit/test_printer_media.py
  16. 6 0
      backend/tests/unit/test_route_auth_coverage.py
  17. 199 0
      frontend/src/__tests__/components/ArchiveMediaDownloadModal.test.tsx
  18. 185 1
      frontend/src/__tests__/components/FileManagerModal.test.tsx
  19. 110 1
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  20. 16 0
      frontend/src/__tests__/pages/PrintersPage.test.tsx
  21. 6 3
      frontend/src/__tests__/pages/PrintersPageDropOnBusy.test.tsx
  22. 105 39
      frontend/src/api/client.ts
  23. 256 0
      frontend/src/components/ArchiveMediaDownloadModal.tsx
  24. 122 66
      frontend/src/components/FileManagerModal.tsx
  25. 22 0
      frontend/src/i18n/locales/de.ts
  26. 22 0
      frontend/src/i18n/locales/en.ts
  27. 22 0
      frontend/src/i18n/locales/es.ts
  28. 22 0
      frontend/src/i18n/locales/fr.ts
  29. 22 0
      frontend/src/i18n/locales/it.ts
  30. 22 0
      frontend/src/i18n/locales/ja.ts
  31. 22 0
      frontend/src/i18n/locales/ko.ts
  32. 22 0
      frontend/src/i18n/locales/pt-BR.ts
  33. 22 0
      frontend/src/i18n/locales/ru.ts
  34. 22 0
      frontend/src/i18n/locales/tr.ts
  35. 22 0
      frontend/src/i18n/locales/uk.ts
  36. 22 0
      frontend/src/i18n/locales/zh-CN.ts
  37. 22 0
      frontend/src/i18n/locales/zh-TW.ts
  38. 50 0
      frontend/src/pages/ArchivesPage.tsx
  39. 1 1
      frontend/src/pages/PrintersPage.tsx
  40. 1 0
      static/assets/index-DjndScv6.css
  41. 0 0
      static/assets/index-DoAzVOXb.js
  42. 0 1
      static/assets/index-DynWy-72.css
  43. 2 2
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **Printer file downloads can be selected in ranges and print-history videos can be downloaded (#2850)** — The printer file browser's multi-select download now prepares large selections on the app data volume instead of buffering them in server and browser memory, uses per-file compression (videos stored, G-code/3MF compressed), reports partial results and preparation progress, supports cancellation, rejects over-large or under-space selections, and preserves the legacy API contract. Shift-click selects a contiguous visible range and hidden selections are discarded when navigating or filtering. Print History now offers attached timelapses, matching printer timelapses, and `/ipcam` chunks when available; offline or unreadable storage is distinguished from an empty directory. Download tokens are single-use and resource-bound, API-key printer allowlists are enforced, FTP short reads are rejected, and abandoned staging is pruned. Translated in all locales; wiki updated. Covered by backend and frontend regression tests.
 - **Bambuddy now asks a printer that refuses FTPS what it actually said (#2780, measured by @grolmus)** — When a printer's file service answers port 990 with something that is not TLS, Python reports `[SSL: WRONG_VERSION_NUMBER]` and the bytes that caused it are gone, consumed by the TLS layer before the error surfaces. That has left #2780 open on a theory rather than a finding. The client now opens one plain connection straight afterwards and reads what the printer says, so the log carries the printer's own words — an FTP refusal such as `421 Too many connections` would identify the fault outright — and the line is marked as the one to quote in a report. Reading nothing is informative too, and says so: a healthy implicit-FTPS service stays silent until it gets a handshake, so silence means the refusal had already passed. It asks once per cool-off window rather than once per attempt, which keeps it to one extra connection per printer per five minutes — the suspected fault is a printer running out of connections, so the diagnosis must not add to it. What made this worth doing is a measurement from a nine-printer farm, reproduced here: a cleartext banner on the TLS port produces exactly the error the field reports, a genuine TLS version mismatch produces a different one, and a client with no version cap reaches a TLS-1.2-only peer unaided. So this failure was never a TLS-version problem, and the per-model `cap_tls_v1_2` knob cannot affect it. Two of the three entries carrying that knob were added on the belief that it could; they are kept, since their reporters saw the symptom clear and nobody here has the hardware to re-test on, but they are now marked for re-test and the reasoning recorded next to them is what was measured rather than what was assumed. Both measurements are pinned by tests, so the explanation stays falsifiable.
 - **The K value is on the AMS slot itself, not only in the popover (#2532, requested and contributed by @gyrene2083)** — Reading back a slot's pressure-advance value meant hovering it: the K factor lived in the filament popover alone, so checking whether a calibration had actually taken across four slots was four hovers, and comparing two of them side by side was not possible at all. Every slot card now carries the value under the material name, the way Bambu Studio shows it per slot — on regular AMS units, on AMS-HT, and on the external spool of a dual-nozzle machine. Only a value the printer actually reported is shown: a loaded but never-calibrated slot stays blank rather than inheriting the 0.020 that fills the popover's own field, and a slot the firmware reports as exactly 0 counts as uncalibrated the same way the stored K-profiles do. The label is shortened to **K** with the full localized name on hover, because "K Factor", "K-Faktor" and "Facteur K" ate the value itself — the whole point of the line — on cards under about 350px, and the figure is set in tabular numerals so it measures the same in Safari as in Chromium. Where one slot of a unit is calibrated and its neighbours are not, the neighbours hold the same row open so the fill bars stay level across the card.
 

+ 208 - 1
backend/app/api/routes/archives.py

@@ -1,3 +1,4 @@
+import asyncio
 import io
 import json
 import logging
@@ -13,27 +14,35 @@ from fastapi.responses import FileResponse, Response
 from sqlalchemy import and_, case, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core import database
 from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    check_printer_access,
+    current_api_key_if_present,
+    probe_permissions_if_auth_enabled,
     require_ownership_permission,
 )
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
+from backend.app.models.printer import Printer
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate
 from backend.app.schemas.print_log import PrintLogResponse
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
+from backend.app.services.bambu_ftp import ftps_handshake_blocked, list_files_result_async
 from backend.app.services.design_settings import overrides_from_config
 from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.services.print_storage import REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE
+from backend.app.services.printer_media import VIDEO_SUFFIXES, match_ipcam_chunks
 from backend.app.utils.archive_paths import archive_photos_dir, find_archive_photo
-from backend.app.utils.http import build_content_disposition
+from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
 from backend.app.utils.threemf_tools import (
     default_plate_gcode_name,
     expand_to_project_slots,
@@ -46,6 +55,7 @@ from backend.app.utils.threemf_tools import (
 logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/archives", tags=["archives"])
+_PRINTER_MEDIA_LIST_TIMEOUT_SECONDS = 8.0
 
 # Path of the embedded slicer config inside a BambuStudio/OrcaSlicer 3MF.
 _PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
@@ -2293,6 +2303,203 @@ async def get_thumbnail(
     )
 
 
+@router.get("/{archive_id}/printer-media")
+async def get_archive_printer_media(
+    archive_id: int,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
+    can_list_printer_files: bool = Depends(probe_permissions_if_auth_enabled(Permission.PRINTERS_FILES)),
+    api_key: APIKey | None = Depends(current_api_key_if_present),
+):
+    """Find downloadable timelapse and `/ipcam` files for one print.
+
+    Local attached timelapses are returned without touching the printer.
+    Printer directories are listed only when the caller also has
+    ``printers:files``; otherwise the local result is returned with a warning.
+    Files are downloaded only after the user explicitly selects them in the UI.
+    """
+
+    user, can_read_all = auth_result
+    async with database.async_session() as db:
+        archive = _ensure_archive_visible(await ArchiveService(db).get_archive(archive_id), user, can_read_all)
+        printer = None
+        claimed_timelapse_stems: set[str] = set()
+        if archive.printer_id is not None:
+            printer = (await db.execute(select(Printer).where(Printer.id == archive.printer_id))).scalar_one_or_none()
+            if printer is not None and archive.timelapse_path is None:
+                claimed_timelapse_stems = await _claimed_timelapse_stems(db, archive.printer_id, archive_id)
+
+    local_timelapse = None
+    if archive.timelapse_path:
+        local_path = settings.base_dir / archive.timelapse_path
+        if await asyncio.to_thread(local_path.is_file):
+            local_timelapse = {
+                "name": local_path.name,
+                "size": (await asyncio.to_thread(local_path.stat)).st_size,
+            }
+
+    response = {
+        "archive_id": archive.id,
+        "printer_id": archive.printer_id,
+        "local_timelapse": local_timelapse,
+        "remote_files": [],
+        "warnings": [],
+    }
+    if archive.printer_id is None or archive.started_at is None:
+        return response
+    if not can_list_printer_files:
+        response["warnings"].append("printer_files_forbidden")
+        return response
+
+    if printer is None:
+        response["warnings"].append("printer_missing")
+        return response
+    if api_key is not None:
+        check_printer_access(api_key, printer.id)
+
+    if ftps_handshake_blocked(printer.ip_address):
+        if local_timelapse is None:
+            response["warnings"].append("timelapse_unavailable")
+        response["warnings"].append("ipcam_unavailable")
+        return response
+
+    remote_files: list[dict] = []
+
+    # If no copy was attached to the archive, offer the matching printer-side
+    # timelapse without mutating the archive or deleting anything from the SD.
+    if local_timelapse is None:
+        videos: list[dict] = []
+        any_timelapse_directory_available = False
+        for timelapse_dir in ("/timelapse", "/timelapse/video", "/record", "/recording"):
+            if ftps_handshake_blocked(printer.ip_address):
+                break
+            listing = await list_files_result_async(
+                printer.ip_address,
+                printer.access_code,
+                timelapse_dir,
+                timeout=_PRINTER_MEDIA_LIST_TIMEOUT_SECONDS,
+                printer_model=printer.model,
+            )
+            any_timelapse_directory_available |= listing.available
+            candidates = [
+                file
+                for file in listing.files
+                if not file.get("is_directory") and str(file.get("name") or "").lower().endswith(VIDEO_SUFFIXES)
+            ]
+            if candidates:
+                videos = candidates
+                break
+        if not any_timelapse_directory_available:
+            response["warnings"].append("timelapse_unavailable")
+        if videos:
+            baseline = set(archive.timelapse_baseline or [])
+            eligible = [
+                file
+                for file in videos
+                if str(file.get("name") or "") not in baseline
+                and Path(str(file.get("name") or "")).stem not in claimed_timelapse_stems
+            ]
+            if archive.timelapse_baseline is not None:
+                candidate = eligible[0] if len(eligible) == 1 else None
+            else:
+                candidate, _ = _match_timelapse_by_timestamp(eligible, archive.started_at)
+            if candidate is not None:
+                remote_files.append(
+                    {
+                        "name": candidate.get("name"),
+                        "path": candidate.get("path"),
+                        "size": candidate.get("size") or 0,
+                        "mtime": candidate.get("mtime"),
+                        "kind": "timelapse",
+                    }
+                )
+
+    if ftps_handshake_blocked(printer.ip_address):
+        response["warnings"].append("ipcam_unavailable")
+        response["remote_files"] = remote_files
+        return response
+
+    ipcam_listing = await list_files_result_async(
+        printer.ip_address,
+        printer.access_code,
+        "/ipcam",
+        timeout=_PRINTER_MEDIA_LIST_TIMEOUT_SECONDS,
+        printer_model=printer.model,
+    )
+    if ipcam_listing.available:
+        for file in match_ipcam_chunks(ipcam_listing.files, archive.started_at, archive.completed_at):
+            remote_files.append(
+                {
+                    "name": file.get("name"),
+                    "path": file.get("path") or f"/ipcam/{file.get('name')}",
+                    "size": file.get("size") or 0,
+                    "mtime": file.get("mtime"),
+                    "kind": "ipcam",
+                }
+            )
+    else:
+        response["warnings"].append("ipcam_unavailable")
+
+    response["remote_files"] = remote_files
+    return response
+
+
+@router.post("/{archive_id}/media-download-token")
+async def create_archive_media_download_token(
+    archive_id: int,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(Permission.ARCHIVES_READ_ALL, Permission.ARCHIVES_READ_OWN)
+    ),
+):
+    """Mint a single-use token bound to an archive's attached timelapse."""
+
+    from backend.app.core.auth import create_slicer_download_token
+
+    user, can_read_all = auth_result
+    async with database.async_session() as db:
+        archive = _ensure_archive_visible(await ArchiveService(db).get_archive(archive_id), user, can_read_all)
+    if not archive.timelapse_path:
+        raise HTTPException(404, "Timelapse not found")
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if not await asyncio.to_thread(timelapse_path.is_file):
+        raise HTTPException(404, "Timelapse file not found")
+    return {
+        "token": await create_slicer_download_token("archive-timelapse", archive_id),
+        "filename": timelapse_path.name,
+    }
+
+
+@router.get("/{archive_id}/media/dl/{token}/{filename}")
+async def download_archive_media_with_token(
+    archive_id: int,
+    token: str,
+    filename: str,
+):
+    """Consume a resource-bound token and stream an attached timelapse."""
+
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not await verify_slicer_download_token(token, "archive-timelapse", archive_id):
+        return download_error_response(403, "This download link has already been used or has expired.")
+    async with database.async_session() as db:
+        archive = await ArchiveService(db).get_archive(archive_id)
+    if not archive or not archive.timelapse_path:
+        return download_error_response(404, "This print has no attached timelapse.")
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if not await asyncio.to_thread(timelapse_path.is_file):
+        return download_error_response(404, "The attached timelapse is no longer on disk.")
+    safe_filename = safe_download_filename(filename, fallback=timelapse_path.name)
+    return FileResponse(
+        path=timelapse_path,
+        filename=safe_filename,
+        headers={"Content-Disposition": build_content_disposition(safe_filename)},
+    )
+
+
 @router.get("/{archive_id}/timelapse")
 async def get_timelapse(
     archive_id: int,

+ 172 - 42
backend/app/api/routes/printers.py

@@ -1,19 +1,22 @@
 import asyncio
 import logging
 import re
+import secrets
 import zipfile
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, HTTPException, Query
-from fastapi.responses import Response
+from fastapi.responses import FileResponse, Response
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
+from starlette.background import BackgroundTask
 
 from backend.app.core import database
 from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
     RequireOverlayTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
     is_auth_enabled,
 )
 from backend.app.core.config import settings
@@ -36,6 +39,8 @@ from backend.app.schemas.printer import (
     NozzleRackSlot,
     PrinterCreate,
     PrinterDiagnosticResult,
+    PrinterFilesDownloadRequest,
+    PrinterFilesJobRequest,
     PrinterResponse,
     PrinterResponseWithSecret,
     PrinterStatus,
@@ -51,7 +56,7 @@ from backend.app.services.bambu_ftp import (
     ftps_handshake_blocked,
     get_cached_3mf,
     get_storage_info_async,
-    list_files_async,
+    list_files_result_async,
 )
 from backend.app.services.print_storage import ftp_probe_paths, print_file_reachable_over_ftp
 from backend.app.services.printer_diagnostic import run_connection_diagnostic
@@ -68,10 +73,23 @@ from backend.app.services.printer_manager import (
     supports_drying_while_printing,
     uniform_tray_filament_hint,
 )
+from backend.app.services.printer_media import (
+    MAX_PRINTER_ZIP_PREPARE_SECONDS,
+    PrinterFilesZipInsufficientSpaceError,
+    PrinterFilesZipTooLargeError,
+    build_printer_file,
+    build_printer_files_zip,
+    cancel_printer_files_job,
+    get_printer_files_job,
+    printer_file_path,
+    printer_files_zip_path,
+    remove_printer_files_zip,
+    start_printer_files_job,
+)
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.filament_types import printer_filament_type
 from backend.app.utils.fts_routing import slot_extruder
-from backend.app.utils.http import build_content_disposition
+from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
 from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, uses_exhaust_fan_label
 
 logger = logging.getLogger(__name__)
@@ -1553,12 +1571,18 @@ async def _load_printer_or_404(printer_id: int) -> Printer:
 async def list_printer_files(
     printer_id: int,
     path: str = "/",
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
 ):
     """List files on the printer at the specified path."""
     printer = await _load_printer_or_404(printer_id)
 
-    files = await list_files_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
+    listing = await list_files_result_async(
+        printer.ip_address,
+        printer.access_code,
+        path,
+        printer_model=printer.model,
+    )
+    files = listing.files
 
     # Add full path to each file
     for f in files:
@@ -1567,6 +1591,7 @@ async def list_printer_files(
     return {
         "path": path,
         "files": files,
+        "warnings": [] if listing.available else ["printer_unavailable"],
     }
 
 
@@ -1574,14 +1599,27 @@ async def list_printer_files(
 async def download_printer_file(
     printer_id: int,
     path: str,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
 ):
     """Download a file from the printer."""
     printer = await _load_printer_or_404(printer_id)
 
-    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
-    if data is None:
+    try:
+        async with asyncio.timeout(MAX_PRINTER_ZIP_PREPARE_SECONDS):
+            result = await build_printer_file(
+                printer,
+                path,
+                None,
+                bundle_key=f"single-{secrets.token_urlsafe(18)}",
+            )
+    except PrinterFilesZipTooLargeError as exc:
+        raise HTTPException(413, str(exc)) from exc
+    except PrinterFilesZipInsufficientSpaceError as exc:
+        raise HTTPException(507, str(exc)) from exc
+    except FileNotFoundError:
         raise HTTPException(404, f"File not found: {path}")
+    except TimeoutError as exc:
+        raise HTTPException(504, "Printer download exceeded the 30-minute limit") from exc
 
     # Determine content type based on extension
     filename = path.split("/")[-1]
@@ -1600,10 +1638,12 @@ async def download_printer_file(
     }
     content_type = content_types.get(ext, "application/octet-stream")
 
-    return Response(
-        content=data,
+    return FileResponse(
+        path=result.path,
+        filename=filename,
         media_type=content_type,
         headers={"Content-Disposition": build_content_disposition(filename)},
+        background=BackgroundTask(remove_printer_files_zip, result.path),
     )
 
 
@@ -1611,7 +1651,7 @@ async def download_printer_file(
 async def get_printer_file_gcode(
     printer_id: int,
     path: str,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
 ):
     """Get gcode for a file stored on a printer (for preview)."""
     import io
@@ -1645,7 +1685,7 @@ async def get_printer_file_gcode(
 async def get_printer_file_plates(
     printer_id: int,
     path: str = Query(..., description="Full path to the 3MF file on the printer"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
 ):
     """Get available plates from a multi-plate 3MF file stored on a printer."""
     import io
@@ -1885,7 +1925,7 @@ async def get_printer_file_plate_thumbnail(
     printer_id: int,
     plate_index: int,
     path: str = Query(..., description="Full path to the 3MF file on the printer"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
 ):
     """Get a plate thumbnail image from a printer-stored 3MF file."""
     import io
@@ -1911,43 +1951,133 @@ async def get_printer_file_plate_thumbnail(
 @router.post("/{printer_id}/files/download-zip")
 async def download_printer_files_as_zip(
     printer_id: int,
-    request: dict,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    request: PrinterFilesDownloadRequest,
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
 ):
-    """Download multiple files from the printer as a ZIP archive."""
-    import io
+    """Download multiple files using a disk-backed ZIP.
 
-    paths = request.get("paths", [])
-    if not paths:
+    Kept backward-compatible for API clients: relative paths are rooted,
+    duplicate paths receive collision-safe names, and an all-failed request
+    returns an empty ZIP as the historical endpoint did. The browser uses the
+    asynchronous preparation endpoints below.
+    """
+    if not request.paths:
         raise HTTPException(400, "No files specified")
+    printer = await _load_printer_or_404(printer_id)
+    normalized_paths = [path if path.startswith("/") else f"/{path}" for path in request.paths]
+    normalized_sizes = {path if path.startswith("/") else f"/{path}": size for path, size in request.sizes.items()}
+    try:
+        async with asyncio.timeout(MAX_PRINTER_ZIP_PREPARE_SECONDS):
+            result = await build_printer_files_zip(
+                printer,
+                normalized_paths,
+                normalized_sizes,
+                preserve_paths=False,
+                allow_empty=True,
+            )
+    except PrinterFilesZipTooLargeError as exc:
+        raise HTTPException(413, str(exc)) from exc
+    except PrinterFilesZipInsufficientSpaceError as exc:
+        raise HTTPException(507, str(exc)) from exc
+    except TimeoutError as exc:
+        raise HTTPException(504, "Printer ZIP preparation exceeded the 30-minute limit") from exc
+    return FileResponse(
+        path=result.path,
+        filename="printer-files.zip",
+        media_type="application/zip",
+        headers={
+            "X-Bambuddy-Files-Requested": str(result.requested),
+            "X-Bambuddy-Files-Downloaded": str(result.successful),
+            "X-Bambuddy-Files-Failed": str(len(result.failed_paths)),
+        },
+        background=BackgroundTask(remove_printer_files_zip, result.path),
+    )
+
+
+@router.post("/{printer_id}/files/download-job")
+async def create_printer_files_download_job(
+    printer_id: int,
+    request: PrinterFilesJobRequest,
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+):
+    """Start a cancellable disk-backed preparation without holding the request."""
 
+    if not request.paths:
+        raise HTTPException(400, "No files specified")
+    if len(set(request.paths)) != len(request.paths):
+        raise HTTPException(400, "Selected printer paths must be unique")
+    if not request.as_zip and len(request.paths) != 1:
+        raise HTTPException(400, "Native downloads require exactly one file")
     printer = await _load_printer_or_404(printer_id)
+    try:
+        status = await start_printer_files_job(
+            printer,
+            request.paths,
+            request.sizes,
+            request.filename,
+            as_zip=request.as_zip,
+        )
+    except PrinterFilesZipTooLargeError as exc:
+        raise HTTPException(413, str(exc)) from exc
+    except PrinterFilesZipInsufficientSpaceError as exc:
+        raise HTTPException(507, str(exc)) from exc
+    except ValueError as exc:
+        raise HTTPException(400, str(exc)) from exc
+    return status.__dict__
 
-    # Create ZIP in memory
-    zip_buffer = io.BytesIO()
-    with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
-        for path in paths:
-            try:
-                data = await download_file_bytes_async(
-                    printer.ip_address, printer.access_code, path, printer_model=printer.model
-                )
-                if data:
-                    filename = path.split("/")[-1]
-                    zf.writestr(filename, data)
-            except Exception as e:
-                logging.warning("Failed to add %s to ZIP: %s", path, e)
-                continue
 
-    zip_buffer.seek(0)
-    zip_data = zip_buffer.read()
+@router.get("/{printer_id}/files/download-jobs/{job_id}")
+async def get_printer_files_download_job(
+    printer_id: int,
+    job_id: str,
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+):
+    status = await get_printer_files_job(job_id, printer_id)
+    if status is None:
+        raise HTTPException(404, "Printer download job not found")
+    return status.__dict__
+
 
-    if len(zip_data) == 0:
-        raise HTTPException(404, "No files could be downloaded")
+@router.delete("/{printer_id}/files/download-jobs/{job_id}")
+async def cancel_printer_files_download_job(
+    printer_id: int,
+    job_id: str,
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+):
+    if not await cancel_printer_files_job(job_id, printer_id):
+        raise HTTPException(404, "Printer download job not found")
+    return {"status": "cancelled"}
 
-    return Response(
-        content=zip_data,
-        media_type="application/zip",
-        headers={"Content-Disposition": 'attachment; filename="printer-files.zip"'},
+
+@router.get("/{printer_id}/files/dl/{token}/{filename}")
+async def download_prepared_printer_files(
+    printer_id: int,
+    token: str,
+    filename: str,
+):
+    """Consume a resource-bound token and stream a prepared file natively."""
+
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not await verify_slicer_download_token(token, "printer-files", printer_id):
+        return download_error_response(403, "This download link has already been used or has expired.")
+    zip_path = printer_files_zip_path(printer_id, token)
+    raw_path = printer_file_path(printer_id, token)
+    if zip_path is not None and await asyncio.to_thread(zip_path.is_file):
+        prepared_path = zip_path
+        media_type = "application/zip"
+    elif raw_path is not None and await asyncio.to_thread(raw_path.is_file):
+        prepared_path = raw_path
+        media_type = "application/octet-stream"
+    else:
+        return download_error_response(404, "The prepared download is no longer on the server.")
+    safe_filename = safe_download_filename(filename, fallback="printer-download")
+    return FileResponse(
+        path=prepared_path,
+        filename=safe_filename,
+        media_type=media_type,
+        headers={"Content-Disposition": build_content_disposition(safe_filename)},
+        background=BackgroundTask(remove_printer_files_zip, prepared_path),
     )
 
 
@@ -1955,7 +2085,7 @@ async def download_printer_files_as_zip(
 async def delete_printer_file(
     printer_id: int,
     path: str,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
 ):
     """Delete a file from the printer."""
     printer = await _load_printer_or_404(printer_id)

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

@@ -4,6 +4,7 @@ import logging
 import os
 import secrets
 import time
+from contextvars import ContextVar
 from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
@@ -1129,6 +1130,15 @@ async def _user_from_api_key(db: AsyncSession, api_key: APIKey) -> User | None:
     return user
 
 
+# The row a successful validation produced for the request in flight. Printer-
+# scoped routes validate the same credential twice -- once in the permission
+# gate, once for the key's printer allowlist -- and a validation is a pbkdf2
+# verify plus a ``last_used`` write, so the second one is pure cost. Keyed by
+# the raw credential so a request carrying two of them can never cross their
+# rows, and held in a ContextVar so it cannot outlive the task that set it.
+_validated_api_key: ContextVar[tuple[str, APIKey] | None] = ContextVar("_validated_api_key", default=None)
+
+
 async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
     """Validate an API key and return the APIKey object if valid, None otherwise.
 
@@ -1163,6 +1173,7 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
                 # Update last_used timestamp
                 api_key.last_used = datetime.now(timezone.utc)
                 await db.commit()
+                _validated_api_key.set((api_key_value, api_key))
                 return api_key
     except Exception as e:  # SEC-AUTH-EXC: validation failure returns None; every caller treats None as "invalid key" → 401 (fail-closed)
         logger.warning("API key validation error: %s", e)
@@ -1625,6 +1636,67 @@ def check_printer_access(api_key: APIKey, printer_id: int) -> None:
         )
 
 
+async def validated_api_key_from_request(
+    credentials: HTTPAuthorizationCredentials | None,
+    x_api_key: str | None,
+) -> APIKey | None:
+    """Return the validated API key carried by a request, if any.
+
+    Permission dependencies intentionally return ``None`` for API-key callers so
+    routes do not mistake a key for a user identity. Printer-bound routes still
+    need the key row to enforce ``printer_ids`` after the normal scope/owner
+    permission gate has run. This helper recognizes both supported transports.
+    """
+
+    candidate = x_api_key
+    if candidate is None and credentials is not None and credentials.credentials.startswith("bb_"):
+        candidate = credentials.credentials
+    if candidate is None:
+        return None
+    cached = _validated_api_key.get()
+    if cached is not None and cached[0] == candidate:
+        return cached[1]
+    async with async_session() as db:
+        api_key = await _validate_api_key(db, candidate)
+        if api_key is None:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Invalid API key",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+        # Touch the JSON-backed value before detaching the row from the session.
+        _ = api_key.printer_ids
+        return api_key
+
+
+async def current_api_key_if_present(
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+) -> APIKey | None:
+    """FastAPI dependency exposing only an authenticated API-key principal."""
+
+    return await validated_api_key_from_request(credentials, x_api_key)
+
+
+def require_printer_permission_if_auth_enabled(permission: str | Permission):
+    """Require a permission and enforce an API key's per-printer allowlist."""
+
+    permission_checker = require_permission_if_auth_enabled(permission)
+
+    async def checker(
+        printer_id: int,
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> User | None:
+        user = await permission_checker(credentials=credentials, x_api_key=x_api_key)
+        api_key = await validated_api_key_from_request(credentials, x_api_key)
+        if api_key is not None:
+            check_printer_access(api_key, printer_id)
+        return user
+
+    return checker
+
+
 # Convenience dependencies - these are functions that return Depends objects
 def RequireAdmin():
     """Dependency that requires admin role."""
@@ -1834,6 +1906,37 @@ def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
     return Depends(require_permission_if_auth_enabled(*permissions))
 
 
+def RequirePrinterPermissionIfAuthEnabled(permission: str | Permission):
+    """Require a permission plus any API-key ``printer_ids`` restriction."""
+
+    return Depends(require_printer_permission_if_auth_enabled(permission))
+
+
+def probe_permissions_if_auth_enabled(*permissions: str | Permission):
+    """Return permission availability while preserving authentication errors.
+
+    This is for endpoints that can return a useful permission-independent
+    subset. Missing permissions become ``False``; invalid or absent credentials
+    still retain the normal 401 response from the shared permission checker.
+    """
+
+    permission_checker = require_permission_if_auth_enabled(*permissions)
+
+    async def checker(
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> bool:
+        try:
+            await permission_checker(credentials, x_api_key)
+        except HTTPException as exc:
+            if exc.status_code == status.HTTP_403_FORBIDDEN:
+                return False
+            raise
+        return True
+
+    return checker
+
+
 def require_any_permission_if_auth_enabled(*permissions: str | Permission):
     """Dependency factory that requires AT LEAST ONE of the given permissions when auth is enabled."""
     perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]

+ 17 - 0
backend/app/main.py

@@ -8091,6 +8091,16 @@ async def lifespan(app: FastAPI):
 
     await init_db()
 
+    # Browser download tokens expire after five minutes. Remove abandoned
+    # prepared ZIPs at startup as well as before each new preparation so a
+    # quiet appliance cannot retain an unusable bundle indefinitely.
+    try:
+        from backend.app.services.printer_media import prune_stale_printer_file_bundles
+
+        await prune_stale_printer_file_bundles()
+    except Exception as exc:
+        logging.warning("Failed to prune stale printer download bundles: %s", exc)
+
     # After migrations, so the is_env_managed column exists. Never raises --
     # a bad BAMBUDDY_OIDC_* value is logged and skipped rather than blocking
     # startup (see apply_env_oidc_provider).
@@ -8507,6 +8517,10 @@ async def lifespan(app: FastAPI):
     # L-2: Start periodic auth cleanup (stale TOTP + expired revoked JTIs)
     start_auth_cleanup()
 
+    from backend.app.services.printer_media import start_printer_download_cleanup
+
+    start_printer_download_cleanup()
+
     # Event-loop stall watchdog: dumps all thread stacks to stderr if the loop
     # freezes (#1486 — silent "container hangs after adding a printer" reports).
     from backend.app.services.loop_watchdog import start_loop_watchdog
@@ -8555,6 +8569,9 @@ async def lifespan(app: FastAPI):
         logging.warning("Failed to shut down camera broadcasters: %s", e)
     stop_expected_prints_cleanup()
     stop_auth_cleanup()
+    from backend.app.services.printer_media import stop_printer_download_cleanup
+
+    await stop_printer_download_cleanup()
     printer_manager.disconnect_all()
     await close_spoolman_client()
 

+ 25 - 1
backend/app/schemas/printer.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, field_validator, model_validator
 
 from backend.app.utils.printer_models import supports_nozzle_flow_type
 
@@ -445,3 +445,27 @@ class DiagnosticRequest(BaseModel):
     ip_address: str
     serial_number: str | None = None
     access_code: str | None = None
+
+
+class PrinterFilesDownloadRequest(BaseModel):
+    """Printer paths selected for a bulk download."""
+
+    paths: list[str] = Field(..., max_length=1000)
+    sizes: dict[str, int] = Field(default_factory=dict, max_length=1000)
+
+    @model_validator(mode="after")
+    def _validate_sizes(self):
+        """Validate optional FTP-reported sizes used for early rejection."""
+
+        if self.sizes and set(self.sizes) != set(self.paths):
+            raise ValueError("A size is required for every selected printer path")
+        if any(size < 0 for size in self.sizes.values()):
+            raise ValueError("Printer file sizes must not be negative")
+        return self
+
+
+class PrinterFilesJobRequest(PrinterFilesDownloadRequest):
+    """Browser preparation request, including native download presentation."""
+
+    filename: str = Field(default="printer-files.zip", min_length=1, max_length=255)
+    as_zip: bool = True

+ 170 - 10
backend/app/services/bambu_ftp.py

@@ -2,6 +2,7 @@ import asyncio
 import ftplib  # nosec B402
 import logging
 import os
+import shutil
 import socket
 import ssl
 import threading
@@ -73,6 +74,26 @@ class UploadCancelled(Exception):
     """
 
 
+class DownloadCancelled(Exception):
+    """Raised in an FTP callback to stop a disk-backed download cooperatively."""
+
+
+class DownloadLimitExceeded(Exception):
+    """Raised before an FTP callback writes beyond its caller-supplied limit."""
+
+
+class DownloadInsufficientSpace(Exception):
+    """Raised before an FTP callback consumes the application's disk reserve."""
+
+
+@dataclass(frozen=True)
+class FileListResult:
+    """A directory listing that distinguishes empty from unreachable."""
+
+    files: list[dict]
+    available: bool
+
+
 class DeleteResult(Enum):
     """Outcome of an FTP delete attempt.
 
@@ -607,7 +628,7 @@ class BambuFTPClient:
                 self._abandon_connection()
             self._ftp = None
 
-    def list_files(self, path: str = "/") -> list[dict]:
+    def list_files(self, path: str = "/", *, raise_on_error: bool = False) -> list[dict]:
         """List files in a directory."""
         if not self._ftp:
             return []
@@ -663,6 +684,8 @@ class BambuFTPClient:
             logger.debug("Listed %s files in %s", len(files), path)
         except (OSError, ftplib.Error) as e:
             logger.info("FTP list_files failed for %s: %s", path, e)
+            if raise_on_error:
+                raise
 
         return files
 
@@ -703,16 +726,60 @@ class BambuFTPClient:
             return None
         return data
 
-    def download_to_file(self, remote_path: str, local_path: Path) -> bool:
-        """Download a file from the printer to local filesystem."""
+    def download_to_file(
+        self,
+        remote_path: str,
+        local_path: Path,
+        *,
+        expected_size: int | None = None,
+        max_bytes: int | None = None,
+        cancel_event: threading.Event | None = None,
+        min_free_bytes: int | None = None,
+    ) -> bool:
+        """Download a file with cooperative cancellation and byte bounds."""
         if not self._ftp:
             logger.warning("download_to_file called but FTP not connected")
             return False
 
         try:
             local_path.parent.mkdir(parents=True, exist_ok=True)
+            # SIZE is the printer's own current view of the file and is more
+            # trustworthy than a browser round-tripped listing hint. Some
+            # firmware does not implement SIZE, so retain expected_size as a
+            # compatibility fallback when the command is unavailable.
+            try:
+                server_size = self._ftp.size(remote_path)
+            except (OSError, ftplib.Error):
+                server_size = None
+            authoritative_size = server_size if server_size is not None and server_size >= 0 else expected_size
+            if max_bytes is not None and authoritative_size is not None and authoritative_size > max_bytes:
+                raise DownloadLimitExceeded(remote_path)
+            if min_free_bytes is not None and authoritative_size is not None:
+                if shutil.disk_usage(local_path.parent).free < min_free_bytes + authoritative_size:
+                    raise DownloadInsufficientSpace(remote_path)
             with open(local_path, "wb") as f:
-                self._ftp.retrbinary(f"RETR {remote_path}", f.write)
+                written = 0
+                # retrbinary hands over 8 KiB at a time, so checking the volume
+                # on every callback is ~30k statvfs calls per 250 MB chunk for a
+                # reserve measured in hundreds of megabytes. Sampling every few
+                # MB cannot overshoot it by more than one interval.
+                free_check_interval = 8 * 1024 * 1024
+                next_free_check = 0
+
+                def _write(chunk: bytes) -> None:
+                    nonlocal written, next_free_check
+                    if cancel_event is not None and cancel_event.is_set():
+                        raise DownloadCancelled(remote_path)
+                    if max_bytes is not None and written + len(chunk) > max_bytes:
+                        raise DownloadLimitExceeded(remote_path)
+                    if min_free_bytes is not None and written >= next_free_check:
+                        next_free_check = written + free_check_interval
+                        if shutil.disk_usage(local_path.parent).free < min_free_bytes + free_check_interval:
+                            raise DownloadInsufficientSpace(remote_path)
+                    f.write(chunk)
+                    written += len(chunk)
+
+                self._ftp.retrbinary(f"RETR {remote_path}", _write)
                 f.flush()
                 os.fsync(f.fileno())
             file_size = local_path.stat().st_size if local_path.exists() else 0
@@ -721,9 +788,18 @@ class BambuFTPClient:
                 if local_path.exists():
                     local_path.unlink()
                 return False
+            if authoritative_size is not None and file_size != authoritative_size:
+                logger.warning(
+                    "FTP download of %s is short: got %s bytes, listing reported %s — treating as failed",
+                    remote_path,
+                    file_size,
+                    authoritative_size,
+                )
+                local_path.unlink(missing_ok=True)
+                return False
             logger.info("Successfully downloaded %s to %s (%s bytes)", remote_path, local_path, file_size)
             return True
-        except (OSError, ftplib.Error) as e:
+        except (OSError, ftplib.Error, DownloadCancelled, DownloadLimitExceeded, DownloadInsufficientSpace) as e:
             # Clean up partial file if it exists
             if local_path.exists():
                 try:
@@ -734,6 +810,8 @@ class BambuFTPClient:
             # with_ftp_retry can abandon this path immediately and the caller
             # can advance to the next candidate instead of retrying 11× at
             # 30s intervals (the pattern that cost #972's reporter ~48min).
+            if isinstance(e, (DownloadCancelled, DownloadLimitExceeded, DownloadInsufficientSpace)):
+                raise
             if isinstance(e, ftplib.error_perm) and str(e).startswith("550"):
                 logger.info("FTP download failed for %s: %s (not on printer)", remote_path, e)
                 raise FileNotOnPrinterError(f"{remote_path}: {e}") from e
@@ -1310,12 +1388,23 @@ async def download_file_async(
     timeout: float = 60.0,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
+    expected_size: int | None = None,
+    max_bytes: int | None = None,
+    cancel_event: threading.Event | None = None,
+    min_free_bytes: int | None = None,
 ) -> bool:
     """Async wrapper for downloading a file with timeout.
 
     For A1/A1 Mini printers, automatically tries prot_p first, then falls back
     to prot_c if the download fails. The working mode is cached for future operations.
 
+    ``timeout`` bounds the wait for a *result*, not the call: when it expires
+    this waits for the FTP worker thread to unwind before returning, because
+    the thread owns ``local_path`` until it does and a caller that came back
+    early would delete a file still being written. That wait is bounded by the
+    socket timeout, so pass ``socket_timeout`` on any path that must not block
+    indefinitely -- every caller here does.
+
     Args:
         ip_address: Printer IP address
         access_code: Printer access code
@@ -1338,9 +1427,24 @@ async def download_file_async(
     # The event is set in `_download`'s finally block so the post-timeout
     # path can wait for genuine thread completion instead of a fixed sleep.
 
-    def _download(force_prot_c: bool, completion: dict, done: threading.Event) -> bool:
+    class _CombinedCancelEvent:
+        def __init__(self, attempt_event: threading.Event):
+            self._attempt_event = attempt_event
+
+        def is_set(self) -> bool:
+            return self._attempt_event.is_set() or (cancel_event is not None and cancel_event.is_set())
+
+    def _download(
+        force_prot_c: bool,
+        completion: dict,
+        done: threading.Event,
+        attempt_cancel: threading.Event,
+    ) -> bool:
         mode_str = "prot_c" if force_prot_c else "prot_p"
         try:
+            combined_cancel = _CombinedCancelEvent(attempt_cancel)
+            if combined_cancel.is_set():
+                raise DownloadCancelled(remote_path)
             client = BambuFTPClient(
                 ip_address,
                 access_code,
@@ -1350,7 +1454,14 @@ async def download_file_async(
             )
             if client.connect():
                 try:
-                    result = client.download_to_file(remote_path, local_path)
+                    result = client.download_to_file(
+                        remote_path,
+                        local_path,
+                        expected_size=expected_size,
+                        max_bytes=max_bytes,
+                        cancel_event=combined_cancel,
+                        min_free_bytes=min_free_bytes,
+                    )
                     if result:
                         BambuFTPClient.cache_mode(ip_address, mode_str)
                         completion["success"] = True
@@ -1364,10 +1475,20 @@ async def download_file_async(
     async def _run(force_prot_c: bool) -> bool:
         completion = {"success": False}
         done = threading.Event()
+        attempt_cancel = threading.Event()
+        worker = loop.run_in_executor(_ftp_executor, _download, force_prot_c, completion, done, attempt_cancel)
         try:
-            return await asyncio.wait_for(
-                loop.run_in_executor(_ftp_executor, _download, force_prot_c, completion, done), timeout=timeout
-            )
+            return await asyncio.wait_for(asyncio.shield(worker), timeout=timeout)
+        except asyncio.CancelledError:
+            # Cancelling an asyncio Future cannot stop its executor thread. Set
+            # the callback-visible flag and do not let the caller unlink the
+            # staging file until the worker has genuinely unwound.
+            attempt_cancel.set()
+            try:
+                await asyncio.shield(worker)
+            except (DownloadCancelled, OSError, ftplib.Error):
+                pass
+            raise
         except TimeoutError:
             # Slow WiFi links commonly overshoot ftp_timeout by 10–30 s without
             # actually being stuck, so starting attempt 2 now would just contend
@@ -1385,7 +1506,16 @@ async def download_file_async(
             # wait for is how you build a deadlock — with enough concurrent
             # timeouts the waiters would occupy every slot and the downloads they
             # are waiting for could never be scheduled.
+            attempt_cancel.set()
             await loop.run_in_executor(None, done.wait, grace)
+            # Wait for the thread either way. If the grace period was enough it
+            # returns at once; if it was not, the blocking socket still has to
+            # reach its own timeout, and returning before it does would let the
+            # caller unlink a file the executor is still writing.
+            try:
+                await asyncio.shield(worker)
+            except (DownloadCancelled, OSError, ftplib.Error):
+                pass
             if completion["success"] and local_path.exists() and local_path.stat().st_size > 0:
                 logger.info(
                     "FTP download wait_for timed out after %ss for %s, but thread completed within %ss grace (%s bytes) — salvaging",
@@ -1705,6 +1835,36 @@ async def list_files_async(
         return []
 
 
+async def list_files_result_async(
+    ip_address: str,
+    access_code: str,
+    path: str = "/",
+    timeout: float = 30.0,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
+) -> FileListResult:
+    """List a directory without collapsing transport failure into empty."""
+
+    loop = asyncio.get_event_loop()
+
+    def _list() -> FileListResult:
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
+        if not client.connect():
+            return FileListResult(files=[], available=False)
+        try:
+            return FileListResult(files=client.list_files(path, raise_on_error=True), available=True)
+        except (OSError, ftplib.Error):
+            return FileListResult(files=[], available=False)
+        finally:
+            client.disconnect()
+
+    try:
+        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _list), timeout=timeout)
+    except TimeoutError:
+        logger.warning("FTP list_files timed out after %ss for %s", timeout, path)
+        return FileListResult(files=[], available=False)
+
+
 async def find_remote_file_async(
     ip_address: str,
     access_code: str,

+ 719 - 0
backend/app/services/printer_media.py

@@ -0,0 +1,719 @@
+"""Helpers for matching and downloading printer-side video files."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import re
+import secrets
+import shutil
+import tempfile
+import time
+import zipfile
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, replace
+from datetime import datetime, timedelta, timezone
+from pathlib import Path, PurePosixPath
+
+from backend.app.core.config import settings
+from backend.app.core.tasks import spawn_background_task
+from backend.app.services.bambu_ftp import (
+    DownloadCancelled,
+    DownloadInsufficientSpace,
+    DownloadLimitExceeded,
+    download_file_async,
+)
+
+logger = logging.getLogger(__name__)
+
+VIDEO_SUFFIXES = (".mp4", ".avi", ".mkv")
+MAX_PRINTER_ZIP_BYTES = 10 * 1024**3
+PRINTER_ZIP_FREE_SPACE_RESERVE = 256 * 1024**2
+_STALE_BUNDLE_SECONDS = 60 * 60
+MAX_PRINTER_ZIP_PREPARE_SECONDS = 30 * 60
+MAX_OPEN_ARCHIVE_IPCAM_SECONDS = 24 * 60 * 60
+_BUNDLE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{1,200}$")
+_JOB_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{20,200}$")
+_LOCAL_JOB_TASKS: dict[str, asyncio.Task] = {}
+_cleanup_task: asyncio.Task | None = None
+_CLEANUP_INTERVAL_SECONDS = 15 * 60
+
+
+class PrinterFilesZipTooLargeError(ValueError):
+    """The selected printer files exceed the bounded ZIP staging limit."""
+
+
+class PrinterFilesZipInsufficientSpaceError(OSError):
+    """The app data volume cannot safely stage the selected files."""
+
+
+@dataclass(frozen=True)
+class PrinterFilesZipResult:
+    """Result of staging one printer ZIP."""
+
+    path: Path
+    requested: int
+    successful: int
+    failed_paths: tuple[str, ...]
+    total_bytes: int
+
+
+@dataclass(frozen=True)
+class PrinterFilesJobStatus:
+    """Serializable state for an asynchronous browser preparation job."""
+
+    job_id: str
+    printer_id: int
+    state: str
+    requested: int
+    successful: int = 0
+    failed: int = 0
+    token: str | None = None
+    filename: str | None = None
+    message: str | None = None
+
+
+class _FileCancelSignal:
+    """Cross-worker cancellation signal checked by the FTP callback thread."""
+
+    def __init__(self, path: Path):
+        self.path = path
+        self._last_check = 0.0
+        self._cached = False
+
+    def is_set(self) -> bool:
+        if self._cached:
+            return True
+        now = time.monotonic()
+        if now - self._last_check >= 0.25:
+            self._last_check = now
+            self._cached = self.path.exists()
+        return self._cached
+
+
+def _job_status_path(job_id: str) -> Path:
+    if not _JOB_KEY_RE.fullmatch(job_id):
+        raise ValueError("Invalid printer download job id")
+    return _printer_zip_root() / f"job-{job_id}.json"
+
+
+def _job_cancel_path(job_id: str) -> Path:
+    if not _JOB_KEY_RE.fullmatch(job_id):
+        raise ValueError("Invalid printer download job id")
+    return _printer_zip_root() / f"job-{job_id}.cancel"
+
+
+def _write_job_status(status: PrinterFilesJobStatus) -> None:
+    """Atomically publish job state for polling from any app worker."""
+
+    path = _job_status_path(status.job_id)
+    temp_path = path.with_suffix(".tmp")
+    temp_path.write_text(json.dumps(status.__dict__, separators=(",", ":")), encoding="utf-8")
+    temp_path.replace(path)
+
+
+def _read_job_status(job_id: str) -> PrinterFilesJobStatus | None:
+    try:
+        data = json.loads(_job_status_path(job_id).read_text(encoding="utf-8"))
+        return PrinterFilesJobStatus(**data)
+    except (FileNotFoundError, OSError, ValueError, TypeError, json.JSONDecodeError):
+        return None
+
+
+def _naive_utc(value: datetime | None) -> datetime | None:
+    if value is None:
+        return None
+    if value.tzinfo is not None:
+        return value.astimezone(timezone.utc).replace(tzinfo=None)
+    return value
+
+
+def match_ipcam_chunks(
+    files: list[dict],
+    started_at: datetime | None,
+    completed_at: datetime | None,
+    *,
+    now: datetime | None = None,
+) -> list[dict]:
+    """Return `/ipcam` chunks whose completion time overlaps a print.
+
+    Bambu's `ipcam-record.*.mp4` files are fixed-size chunks. On the tested X1C
+    and H2D firmware, their FTP mtime is the chunk completion time in the same
+    UTC-naive basis used by archive timestamps. Some firmware reports FTP LIST
+    mtimes in printer-local time instead; LIST carries no timezone with which
+    to correct those values reliably. A ten-minute tail includes the final
+    chunk, whose mtime lands after the print-complete event.
+    """
+
+    start = _naive_utc(started_at)
+    if start is None:
+        return []
+    live_end = _naive_utc(now) or datetime.now(timezone.utc).replace(tzinfo=None)
+    # A crash can leave an archive in ``printing`` indefinitely. Do not turn
+    # that stale row into a window covering every chunk created since then.
+    end = _naive_utc(completed_at) or min(live_end, start + timedelta(seconds=MAX_OPEN_ARCHIVE_IPCAM_SECONDS))
+    lower = start - timedelta(minutes=1)
+    upper = max(start, end) + timedelta(minutes=10)
+
+    matches: list[dict] = []
+    for file in files:
+        name = str(file.get("name") or "")
+        mtime = file.get("mtime")
+        if file.get("is_directory") or not name.lower().startswith("ipcam-record."):
+            continue
+        if not name.lower().endswith(VIDEO_SUFFIXES) or not isinstance(mtime, datetime):
+            continue
+        timestamp = _naive_utc(mtime)
+        if timestamp is not None and lower <= timestamp <= upper:
+            matches.append(file)
+
+    matches.sort(key=lambda item: _naive_utc(item.get("mtime")) or datetime.min)
+    return matches
+
+
+def _zip_arcname(remote_path: str, used: set[str]) -> str:
+    """Return a safe, unique relative archive name for a printer path."""
+
+    parts = [part for part in PurePosixPath(remote_path).parts if part not in ("/", "", ".", "..")]
+    candidate = "/".join(parts) or "printer-file"
+    stem = candidate
+    suffix = ""
+    if "." in PurePosixPath(candidate).name:
+        suffix = "".join(PurePosixPath(candidate).suffixes)
+        stem = candidate[: -len(suffix)] if suffix else candidate
+    counter = 2
+    while candidate in used:
+        candidate = f"{stem}-{counter}{suffix}"
+        counter += 1
+    used.add(candidate)
+    return candidate
+
+
+def _printer_zip_root() -> Path:
+    """Return the dedicated staging root without doing event-loop I/O."""
+
+    return settings.archive_dir / "temp" / "printer-file-downloads"
+
+
+def _ensure_printer_zip_root() -> Path:
+    """Create and return the staging root on the persistent data volume."""
+
+    root = _printer_zip_root()
+    root.mkdir(parents=True, exist_ok=True)
+    return root
+
+
+def _prune_stale_bundles(root: Path) -> None:
+    """Remove abandoned bundles after token expiry, without touching archives."""
+
+    cutoff = time.time() - _STALE_BUNDLE_SECONDS
+    if not root.exists():
+        return
+    for child in root.iterdir():
+        try:
+            if child.is_dir() and child.stat().st_mtime < cutoff:
+                shutil.rmtree(child, ignore_errors=True)
+            elif child.is_file() and child.name.startswith("job-") and child.stat().st_mtime < cutoff:
+                child.unlink(missing_ok=True)
+        except OSError:
+            continue
+
+
+async def prune_stale_printer_file_bundles() -> None:
+    """Prune abandoned printer ZIPs without blocking the event loop."""
+
+    root = await asyncio.to_thread(_ensure_printer_zip_root)
+    await asyncio.to_thread(_prune_stale_bundles, root)
+
+
+async def _printer_download_cleanup_loop() -> None:
+    while True:
+        try:
+            await asyncio.sleep(_CLEANUP_INTERVAL_SECONDS)
+            await prune_stale_printer_file_bundles()
+        except asyncio.CancelledError:
+            break
+        except Exception:
+            logger.exception("Periodic printer-download cleanup failed")
+
+
+def start_printer_download_cleanup() -> None:
+    global _cleanup_task
+    if _cleanup_task is None:
+        _cleanup_task = spawn_background_task(_printer_download_cleanup_loop(), name="printer-download-cleanup")
+
+
+async def stop_printer_download_cleanup() -> None:
+    """Stop cleanup and cancel every in-process preparation before shutdown."""
+
+    global _cleanup_task
+    tasks: list[asyncio.Task] = []
+    cleanup_task = _cleanup_task
+    _cleanup_task = None
+    if cleanup_task is not None:
+        cleanup_task.cancel()
+        tasks.append(cleanup_task)
+
+    # Jobs can be inside an FTP worker thread. Publish the same cooperative
+    # cancellation marker used by the DELETE endpoint before cancelling the
+    # asyncio wrapper, then await every wrapper so no executor work is left
+    # behind when the application event loop closes.
+    for job_id, task in list(_LOCAL_JOB_TASKS.items()):
+        if not task.done():
+            await asyncio.to_thread(_job_cancel_path(job_id).touch)
+            task.cancel()
+        tasks.append(task)
+    if tasks:
+        await asyncio.gather(*tasks, return_exceptions=True)
+    _LOCAL_JOB_TASKS.clear()
+
+
+def printer_files_zip_path(printer_id: int, token: str) -> Path | None:
+    """Resolve the staged ZIP for a resource-bound browser token."""
+
+    bundle_key = f"{printer_id}-{token}"
+    if not _BUNDLE_KEY_RE.fullmatch(bundle_key):
+        return None
+    return _printer_zip_root() / bundle_key / "printer-files.zip"
+
+
+def bind_printer_files_zip_to_token(
+    result: PrinterFilesZipResult,
+    printer_id: int,
+    token: str,
+) -> PrinterFilesZipResult:
+    """Move a prepared bundle to the path derived from its persisted token."""
+
+    target = printer_files_zip_path(printer_id, token)
+    if target is None:
+        raise ValueError("Invalid printer ZIP token")
+    result.path.parent.rename(target.parent)
+    return replace(result, path=target)
+
+
+def _check_initial_space(root: Path, sizes: dict[str, int]) -> None:
+    # These sizes are client-reported hints used only for an early rejection,
+    # so this is a courtesy, not the bound. The real one is enforced per write
+    # and per FTP callback below, against actual bytes and the live free space,
+    # which is the only thing that can hold when several preparations run at
+    # once -- and they do: nothing serializes them. Two concurrent jobs that
+    # both pass here stop independently at the reserve, and the one that gets
+    # there second fails with a message saying so.
+    expected_total = sum(sizes.values())
+    if expected_total > MAX_PRINTER_ZIP_BYTES:
+        raise PrinterFilesZipTooLargeError(
+            f"Selected files total {expected_total} bytes; the limit is {MAX_PRINTER_ZIP_BYTES} bytes"
+        )
+
+    largest_file = max(sizes.values(), default=0)
+    # In the worst case the ZIP is as large as the inputs while the largest
+    # source is still staged beside it. Keep a reserve for the database/logs.
+    required = expected_total + largest_file + PRINTER_ZIP_FREE_SPACE_RESERVE
+    free = shutil.disk_usage(root).free
+    if free < required:
+        raise PrinterFilesZipInsufficientSpaceError(
+            f"The app data volume needs {required} bytes free to stage this selection; {free} bytes are available"
+        )
+
+
+async def build_printer_files_zip(
+    printer,
+    paths: list[str],
+    sizes: dict[str, int],
+    *,
+    bundle_key: str | None = None,
+    preserve_paths: bool = True,
+    allow_empty: bool = False,
+    cancel_signal: _FileCancelSignal | None = None,
+    progress_callback: Callable[[int, int], Awaitable[None]] | None = None,
+) -> PrinterFilesZipResult:
+    """Download printer files one at a time into a disk-backed ZIP.
+
+    The previous implementation held every source file and the final ZIP in
+    memory. Continuous `/ipcam` chunks are commonly ~250 MB each, so selecting
+    only a few could exhaust both server and browser memory.
+    """
+
+    root = await asyncio.to_thread(_ensure_printer_zip_root)
+    await asyncio.to_thread(_prune_stale_bundles, root)
+    await asyncio.to_thread(_check_initial_space, root, sizes)
+    bundle_dir: Path | None = None
+    try:
+        if bundle_key is None:
+            bundle_dir = Path(await asyncio.to_thread(tempfile.mkdtemp, prefix="bundle-", dir=root))
+        else:
+            if not _BUNDLE_KEY_RE.fullmatch(bundle_key):
+                raise ValueError("Invalid printer ZIP bundle key")
+            bundle_dir = root / bundle_key
+            await asyncio.to_thread(bundle_dir.mkdir, mode=0o700)
+        zip_path = bundle_dir / "printer-files.zip"
+        successful = 0
+        total_bytes = 0
+        failed_paths: list[str] = []
+        used_names: set[str] = set()
+
+        archive = await asyncio.to_thread(zipfile.ZipFile, zip_path, "w", allowZip64=True)
+        try:
+            for index, remote_path in enumerate(paths):
+                if cancel_signal is not None and cancel_signal.is_set():
+                    raise asyncio.CancelledError
+                if not isinstance(remote_path, str) or not remote_path.startswith("/") or "\x00" in remote_path:
+                    logger.warning("Skipping invalid printer file path: %r", remote_path)
+                    failed_paths.append(remote_path)
+                    continue
+                staged_path = bundle_dir / f"download-{index}"
+                try:
+                    expected_size = sizes.get(remote_path)
+                    if expected_size is not None:
+                        free = (await asyncio.to_thread(shutil.disk_usage, root)).free
+                        if free < expected_size + PRINTER_ZIP_FREE_SPACE_RESERVE:
+                            raise PrinterFilesZipInsufficientSpaceError(
+                                "The app data volume lacks space for the next selected file"
+                            )
+                    downloaded = await download_file_async(
+                        printer.ip_address,
+                        printer.access_code,
+                        remote_path,
+                        staged_path,
+                        timeout=600,
+                        socket_timeout=60,
+                        printer_model=printer.model,
+                        expected_size=expected_size,
+                        max_bytes=MAX_PRINTER_ZIP_BYTES - total_bytes,
+                        cancel_event=cancel_signal,
+                        min_free_bytes=PRINTER_ZIP_FREE_SPACE_RESERVE,
+                    )
+                    if not downloaded:
+                        failed_paths.append(remote_path)
+                        continue
+                    # Deliberately no second size comparison here. The transfer
+                    # was already checked against the printer's own SIZE, which
+                    # download_to_file treats as the authority precisely because
+                    # it beats a hint the browser round-tripped; re-judging the
+                    # result against that hint would overrule the better number
+                    # with the worse one. The hint goes stale in exactly the case
+                    # this feature exists for -- an /ipcam chunk or a timelapse
+                    # still being written when the listing was taken -- and a
+                    # complete file would then be dropped as "truncated".
+                    file_size = (await asyncio.to_thread(staged_path.stat)).st_size
+                    if total_bytes + file_size > MAX_PRINTER_ZIP_BYTES:
+                        raise PrinterFilesZipTooLargeError(
+                            f"Downloaded files exceed the {MAX_PRINTER_ZIP_BYTES}-byte limit"
+                        )
+                    free = (await asyncio.to_thread(shutil.disk_usage, root)).free
+                    if free < file_size + PRINTER_ZIP_FREE_SPACE_RESERVE:
+                        raise PrinterFilesZipInsufficientSpaceError(
+                            "The app data volume ran out of safe staging space while building the ZIP"
+                        )
+                    compression = (
+                        zipfile.ZIP_STORED if remote_path.lower().endswith(VIDEO_SUFFIXES) else zipfile.ZIP_DEFLATED
+                    )
+                    arc_source = remote_path if preserve_paths else PurePosixPath(remote_path).name
+                    await asyncio.to_thread(
+                        archive.write,
+                        staged_path,
+                        _zip_arcname(arc_source, used_names),
+                        compress_type=compression,
+                    )
+                    successful += 1
+                    total_bytes += file_size
+                except DownloadLimitExceeded as exc:
+                    raise PrinterFilesZipTooLargeError(
+                        f"Downloaded files exceed the {MAX_PRINTER_ZIP_BYTES}-byte limit"
+                    ) from exc
+                except DownloadInsufficientSpace as exc:
+                    raise PrinterFilesZipInsufficientSpaceError(
+                        "The app data volume ran out of safe staging space during transfer"
+                    ) from exc
+                except DownloadCancelled as exc:
+                    raise asyncio.CancelledError from exc
+                except (PrinterFilesZipTooLargeError, PrinterFilesZipInsufficientSpaceError):
+                    raise
+                except Exception as exc:
+                    logger.warning("Failed to add %s to printer ZIP: %s", remote_path, exc)
+                    failed_paths.append(remote_path)
+                finally:
+                    await asyncio.to_thread(staged_path.unlink, missing_ok=True)
+                    if progress_callback is not None:
+                        await progress_callback(successful, len(failed_paths))
+        finally:
+            await asyncio.shield(asyncio.to_thread(archive.close))
+    except BaseException:
+        if bundle_dir is not None:
+            await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
+        raise
+
+    if successful == 0 and not allow_empty:
+        await asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True)
+        raise FileNotFoundError("No files could be downloaded")
+    return PrinterFilesZipResult(
+        path=zip_path,
+        requested=len(paths),
+        successful=successful,
+        failed_paths=tuple(failed_paths),
+        total_bytes=total_bytes,
+    )
+
+
+def printer_file_path(printer_id: int, token: str) -> Path | None:
+    """Resolve a prepared native single-file download."""
+
+    bundle_key = f"{printer_id}-{token}"
+    if not _BUNDLE_KEY_RE.fullmatch(bundle_key):
+        return None
+    return _printer_zip_root() / bundle_key / "printer-file"
+
+
+def bind_printer_file_to_token(result: PrinterFilesZipResult, printer_id: int, token: str) -> PrinterFilesZipResult:
+    target = printer_file_path(printer_id, token)
+    if target is None:
+        raise ValueError("Invalid printer file token")
+    result.path.parent.rename(target.parent)
+    return replace(result, path=target)
+
+
+async def build_printer_file(
+    printer,
+    remote_path: str,
+    expected_size: int | None,
+    *,
+    bundle_key: str,
+    cancel_signal: _FileCancelSignal | None = None,
+) -> PrinterFilesZipResult:
+    """Stage one printer file on disk for a browser-native download.
+
+    Also the read path for the 3MF preview in the file browser, which is why
+    nothing here waits on a shared lock: a preview must not queue behind
+    somebody else's ten-gigabyte selection for as long as that takes.
+    """
+
+    if not remote_path.startswith("/") or "\x00" in remote_path:
+        raise FileNotFoundError("Invalid printer file path")
+    root = await asyncio.to_thread(_ensure_printer_zip_root)
+    size_hints = {remote_path: expected_size} if expected_size is not None else {}
+    await asyncio.to_thread(_check_initial_space, root, size_hints)
+    bundle_dir = root / bundle_key
+    try:
+        await asyncio.to_thread(bundle_dir.mkdir, mode=0o700)
+        local_path = bundle_dir / "printer-file"
+        downloaded = await download_file_async(
+            printer.ip_address,
+            printer.access_code,
+            remote_path,
+            local_path,
+            timeout=600,
+            socket_timeout=60,
+            printer_model=printer.model,
+            expected_size=expected_size,
+            max_bytes=MAX_PRINTER_ZIP_BYTES,
+            cancel_event=cancel_signal,
+            min_free_bytes=PRINTER_ZIP_FREE_SPACE_RESERVE,
+        )
+        if not downloaded:
+            raise FileNotFoundError("The selected printer file could not be downloaded")
+        file_size = (await asyncio.to_thread(local_path.stat)).st_size
+        return PrinterFilesZipResult(
+            path=local_path,
+            requested=1,
+            successful=1,
+            failed_paths=(),
+            total_bytes=file_size,
+        )
+    except DownloadLimitExceeded as exc:
+        await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
+        raise PrinterFilesZipTooLargeError(f"Downloaded file exceeds the {MAX_PRINTER_ZIP_BYTES}-byte limit") from exc
+    except DownloadInsufficientSpace as exc:
+        await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
+        raise PrinterFilesZipInsufficientSpaceError(
+            "The app data volume ran out of safe staging space during transfer"
+        ) from exc
+    except DownloadCancelled as exc:
+        await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
+        raise asyncio.CancelledError from exc
+    except BaseException:
+        await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
+        raise
+
+
+async def _run_printer_files_job(
+    printer,
+    job_id: str,
+    paths: list[str],
+    sizes: dict[str, int],
+    filename: str,
+    as_zip: bool,
+) -> None:
+    from backend.app.core.auth import create_slicer_download_token
+
+    cancel_signal = _FileCancelSignal(_job_cancel_path(job_id))
+    status = PrinterFilesJobStatus(job_id, printer.id, "preparing", len(paths), filename=filename)
+    await asyncio.to_thread(_write_job_status, status)
+
+    async def report_progress(successful: int, failed: int) -> None:
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(
+                job_id,
+                printer.id,
+                "preparing",
+                len(paths),
+                successful=successful,
+                failed=failed,
+                filename=filename,
+            ),
+        )
+
+    try:
+        async with asyncio.timeout(MAX_PRINTER_ZIP_PREPARE_SECONDS):
+            if as_zip:
+                result = await build_printer_files_zip(
+                    printer,
+                    paths,
+                    sizes,
+                    bundle_key=f"job-{job_id}",
+                    cancel_signal=cancel_signal,
+                    progress_callback=report_progress,
+                )
+            else:
+                result = await build_printer_file(
+                    printer,
+                    paths[0],
+                    sizes.get(paths[0]),
+                    bundle_key=f"job-{job_id}",
+                    cancel_signal=cancel_signal,
+                )
+        if cancel_signal.is_set():
+            await asyncio.to_thread(remove_printer_files_zip, result.path)
+            raise asyncio.CancelledError
+        token = await create_slicer_download_token("printer-files", printer.id)
+        if as_zip:
+            result = await asyncio.to_thread(bind_printer_files_zip_to_token, result, printer.id, token)
+        else:
+            result = await asyncio.to_thread(bind_printer_file_to_token, result, printer.id, token)
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(
+                job_id,
+                printer.id,
+                "ready",
+                len(paths),
+                successful=result.successful,
+                failed=len(result.failed_paths),
+                token=token,
+                filename=filename,
+            ),
+        )
+    except asyncio.CancelledError:
+        await asyncio.shield(
+            asyncio.to_thread(
+                _write_job_status,
+                PrinterFilesJobStatus(job_id, printer.id, "cancelled", len(paths), filename=filename),
+            )
+        )
+    except PrinterFilesZipTooLargeError as exc:
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(job_id, printer.id, "failed", len(paths), filename=filename, message=str(exc)),
+        )
+    except PrinterFilesZipInsufficientSpaceError as exc:
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(job_id, printer.id, "failed", len(paths), filename=filename, message=str(exc)),
+        )
+    except TimeoutError:
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(
+                job_id,
+                printer.id,
+                "failed",
+                len(paths),
+                filename=filename,
+                message="Printer download preparation exceeded the 30-minute limit",
+            ),
+        )
+    except FileNotFoundError as exc:
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(job_id, printer.id, "failed", len(paths), filename=filename, message=str(exc)),
+        )
+    except Exception:
+        logger.exception("Printer download job %s failed", job_id)
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(
+                job_id,
+                printer.id,
+                "failed",
+                len(paths),
+                filename=filename,
+                message="Printer download preparation failed",
+            ),
+        )
+    finally:
+        await asyncio.to_thread(_job_cancel_path(job_id).unlink, missing_ok=True)
+
+
+async def start_printer_files_job(
+    printer,
+    paths: list[str],
+    sizes: dict[str, int],
+    filename: str,
+    *,
+    as_zip: bool,
+) -> PrinterFilesJobStatus:
+    """Start a bounded background preparation and return immediately."""
+
+    if not paths:
+        raise ValueError("No files specified")
+    root = await asyncio.to_thread(_ensure_printer_zip_root)
+    await asyncio.to_thread(_prune_stale_bundles, root)
+    await asyncio.to_thread(_check_initial_space, root, sizes)
+    job_id = secrets.token_urlsafe(24)
+    status = PrinterFilesJobStatus(job_id, printer.id, "queued", len(paths), filename=filename)
+    await asyncio.to_thread(_write_job_status, status)
+    task = spawn_background_task(
+        _run_printer_files_job(printer, job_id, paths, sizes, filename, as_zip),
+        name=f"printer-download-{printer.id}-{job_id}",
+    )
+    _LOCAL_JOB_TASKS[job_id] = task
+    task.add_done_callback(lambda _task: _LOCAL_JOB_TASKS.pop(job_id, None))
+    return status
+
+
+async def get_printer_files_job(job_id: str, printer_id: int) -> PrinterFilesJobStatus | None:
+    status = await asyncio.to_thread(_read_job_status, job_id)
+    if status is None or status.printer_id != printer_id:
+        return None
+    return status
+
+
+async def cancel_printer_files_job(job_id: str, printer_id: int) -> bool:
+    status = await get_printer_files_job(job_id, printer_id)
+    if status is None:
+        return False
+    await asyncio.to_thread(_job_cancel_path(job_id).touch)
+    task = _LOCAL_JOB_TASKS.get(job_id)
+    if task is not None and not task.done():
+        task.cancel()
+    if status.state == "ready" and status.token:
+        zip_path = printer_files_zip_path(printer_id, status.token)
+        prepared = (
+            zip_path
+            if zip_path is not None and await asyncio.to_thread(zip_path.is_file)
+            else printer_file_path(printer_id, status.token)
+        )
+        if prepared is not None:
+            await asyncio.to_thread(remove_printer_files_zip, prepared)
+        await asyncio.to_thread(
+            _write_job_status,
+            replace(status, state="cancelled", token=None),
+        )
+    return True
+
+
+def remove_printer_files_zip(zip_path: Path) -> None:
+    """Remove a completed download bundle after FileResponse finishes."""
+
+    shutil.rmtree(zip_path.parent, ignore_errors=True)

+ 36 - 0
backend/app/utils/http.py

@@ -1,7 +1,43 @@
 """HTTP response helpers."""
 
+from pathlib import Path
 from urllib.parse import quote
 
+from starlette.responses import PlainTextResponse
+
+
+def download_error_response(status_code: int, message: str) -> PlainTextResponse:
+    """Answer a browser-native download with a file that says what went wrong.
+
+    These URLs are reached by an ``<a download>`` click, and a browser saves
+    whatever comes back under the name it was going to use. A JSON error body
+    therefore lands on the user's disk as a .zip that will not open, with
+    nothing on screen to explain it -- the download simply appears to have
+    produced a broken file. A short text file, named for the failure rather
+    than for the download, is at least legible when opened.
+    """
+
+    return PlainTextResponse(
+        f"{message}\n",
+        status_code=status_code,
+        headers={"Content-Disposition": build_content_disposition("download-failed.txt")},
+    )
+
+
+def safe_download_filename(filename: str, fallback: str = "download", max_chars: int = 200) -> str:
+    """Return a basename safe for a bounded download response header."""
+
+    basename = Path(filename.replace("\\", "/")).name
+    cleaned = "".join("_" if ord(char) < 32 or ord(char) == 127 else char for char in basename).strip(" .")
+    if not cleaned:
+        return fallback
+    if len(cleaned) <= max_chars:
+        return cleaned
+    suffixes = "".join(Path(cleaned).suffixes)
+    suffix = suffixes if len(suffixes) <= 32 else ""
+    stem_chars = max(1, max_chars - len(suffix))
+    return f"{cleaned[:stem_chars]}{suffix}"
+
 
 def build_content_disposition(filename: str, disposition: str = "attachment") -> str:
     """Build an RFC 6266-compliant Content-Disposition header value.

+ 9 - 7
backend/tests/conftest.py

@@ -169,7 +169,7 @@ def reset_auth_enabled_cache():
 
 @pytest.fixture(autouse=True)
 def disconnect_printers_registered_during_a_test():
-    """Hand the ``printer_manager`` singleton back the way the test found it.
+    """Give every test an empty ``printer_manager`` singleton.
 
     ``POST /api/v1/printers`` really calls ``connect_printer``, so a test that
     creates a printer through the API parks a live client in the singleton --
@@ -179,16 +179,18 @@ def disconnect_printers_registered_during_a_test():
     ``test_scheduled_drying_routes`` saw exactly that: an "online" printer with
     no firmware version, so scheduling a dry came back 400 instead of 200.
 
-    Only ids this test added are dropped, so a client registered by a wider
-    fixture stays registered. ``disconnect_printer`` is what clears the model
-    and printer-info caches too, and it stops the paho thread the leaked client
-    would otherwise keep retrying on for the rest of the run.
+    Snapshotting the ids at test entry was insufficient: a client leaked by a
+    previous module became part of that snapshot and therefore survived every
+    later cleanup on the same xdist worker. Clear both before and after each
+    test. ``disconnect_printer`` also clears model/printer-info caches and stops
+    any paho thread owned by the leaked client.
     """
     from backend.app.services.printer_manager import printer_manager
 
-    before = set(printer_manager._clients)
+    for printer_id in list(printer_manager._clients):
+        printer_manager.disconnect_printer(printer_id)
     yield
-    for printer_id in set(printer_manager._clients) - before:
+    for printer_id in list(printer_manager._clients):
         printer_manager.disconnect_printer(printer_id)
 
 

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

@@ -3,12 +3,16 @@
 Tests the full request/response cycle for /api/v1/archives/ endpoints.
 """
 
+from datetime import datetime
 from pathlib import Path
 from unittest.mock import AsyncMock, patch
 
 import pytest
 from httpx import AsyncClient
 
+from backend.app.core.config import settings
+from backend.app.services.bambu_ftp import FileListResult
+
 
 class TestArchivesAPI:
     """Integration tests for /api/v1/archives/ endpoints."""
@@ -251,6 +255,460 @@ class TestArchivesAPI:
 
         assert response.status_code == 404
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_archive_printer_media_matches_timelapse_and_ipcam_chunks(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        db_session,
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            started_at=datetime(2026, 8, 12, 10, 0),
+            completed_at=datetime(2026, 8, 12, 11, 0),
+            timelapse_path=None,
+        )
+
+        list_timeouts = []
+
+        async def fake_list(_ip, _code, path, **kwargs):
+            list_timeouts.append(kwargs.get("timeout"))
+            if path == "/timelapse":
+                return FileListResult(
+                    files=[
+                        {
+                            "name": "video_2026-08-12_18-00-00.mp4",
+                            "path": "/timelapse/video_2026-08-12_18-00-00.mp4",
+                            "size": 123,
+                            "mtime": datetime(2026, 8, 12, 11, 1),
+                            "is_directory": False,
+                        }
+                    ],
+                    available=True,
+                )
+            if path == "/ipcam":
+                return FileListResult(
+                    files=[
+                        {
+                            "name": "ipcam-record.1.mp4",
+                            "path": "/ipcam/ipcam-record.1.mp4",
+                            "size": 250_000_000,
+                            "mtime": datetime(2026, 8, 12, 10, 5),
+                            "is_directory": False,
+                        },
+                        {
+                            "name": "ipcam-record.after.mp4",
+                            "path": "/ipcam/ipcam-record.after.mp4",
+                            "size": 250_000_000,
+                            "mtime": datetime(2026, 8, 12, 11, 30),
+                            "is_directory": False,
+                        },
+                    ],
+                    available=True,
+                )
+            return FileListResult(files=[], available=False)
+
+        with (
+            patch("backend.app.api.routes.archives.list_files_result_async", new=AsyncMock(side_effect=fake_list)),
+            patch("backend.app.api.routes.archives.ftps_handshake_blocked", return_value=False),
+        ):
+            response = await async_client.get(f"/api/v1/archives/{archive.id}/printer-media")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["local_timelapse"] is None
+        assert [(file["kind"], file["name"]) for file in data["remote_files"]] == [
+            ("timelapse", "video_2026-08-12_18-00-00.mp4"),
+            ("ipcam", "ipcam-record.1.mp4"),
+        ]
+        assert list_timeouts == [8.0, 8.0]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_printer_media_skips_ftp_during_handshake_cooloff(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            started_at=datetime(2026, 8, 12, 10, 0),
+            completed_at=datetime(2026, 8, 12, 11, 0),
+            timelapse_path=None,
+        )
+        list_files = AsyncMock()
+
+        with (
+            patch("backend.app.api.routes.archives.ftps_handshake_blocked", return_value=True),
+            patch("backend.app.api.routes.archives.list_files_result_async", new=list_files),
+        ):
+            response = await async_client.get(f"/api/v1/archives/{archive.id}/printer-media")
+
+        assert response.status_code == 200
+        assert response.json()["remote_files"] == []
+        assert response.json()["warnings"] == ["timelapse_unavailable", "ipcam_unavailable"]
+        list_files.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_printer_media_checks_alternate_timelapse_directories_after_empty_listing(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            started_at=datetime(2026, 8, 12, 10, 0),
+            completed_at=datetime(2026, 8, 12, 11, 0),
+            timelapse_path=None,
+        )
+        paths: list[str] = []
+
+        async def fake_list(_ip, _code, path, **_kwargs):
+            paths.append(path)
+            if path == "/timelapse/video":
+                return FileListResult(
+                    files=[
+                        {
+                            "name": "video_2026-08-12_11-01-00.mp4",
+                            "path": "/timelapse/video/video_2026-08-12_11-01-00.mp4",
+                            "size": 321,
+                            "mtime": datetime(2026, 8, 12, 11, 1),
+                            "is_directory": False,
+                        }
+                    ],
+                    available=True,
+                )
+            return FileListResult(files=[], available=True)
+
+        with (
+            patch("backend.app.api.routes.archives.list_files_result_async", new=AsyncMock(side_effect=fake_list)),
+            patch("backend.app.api.routes.archives.ftps_handshake_blocked", return_value=False),
+        ):
+            response = await async_client.get(f"/api/v1/archives/{archive.id}/printer-media")
+
+        assert response.status_code == 200
+        assert response.json()["remote_files"][0]["path"].startswith("/timelapse/video/")
+        assert paths == ["/timelapse", "/timelapse/video", "/ipcam"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_printer_media_releases_db_session_before_ftp(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        monkeypatch,
+    ):
+        from backend.app.api.routes import archives as archives_routes
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            started_at=datetime(2026, 8, 12, 10, 0),
+            timelapse_path=None,
+        )
+        real_factory = archives_routes.database.async_session
+        session_closed = False
+
+        class TrackingSession:
+            async def __aenter__(self):
+                self.context = real_factory()
+                return await self.context.__aenter__()
+
+            async def __aexit__(self, *args):
+                nonlocal session_closed
+                result = await self.context.__aexit__(*args)
+                session_closed = True
+                return result
+
+        async def fake_list(*_args, **_kwargs):
+            assert session_closed
+            return FileListResult(files=[], available=True)
+
+        monkeypatch.setattr(archives_routes.database, "async_session", TrackingSession)
+        with (
+            patch("backend.app.api.routes.archives.list_files_result_async", new=AsyncMock(side_effect=fake_list)),
+            patch("backend.app.api.routes.archives.ftps_handshake_blocked", return_value=False),
+        ):
+            response = await async_client.get(f"/api/v1/archives/{archive.id}/printer-media")
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_printer_media_baseline_excludes_old_timestamp_match(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            started_at=datetime(2026, 8, 12, 10, 0),
+            completed_at=datetime(2026, 8, 12, 11, 0),
+            timelapse_path=None,
+            timelapse_baseline=["old.mp4"],
+        )
+
+        async def fake_list(_ip, _code, path, **_kwargs):
+            if path == "/timelapse":
+                return FileListResult(
+                    files=[
+                        {
+                            "name": "old.mp4",
+                            "path": "/timelapse/old.mp4",
+                            "size": 10,
+                            "mtime": datetime(2026, 8, 12, 11, 0),
+                            "is_directory": False,
+                        },
+                        {
+                            "name": "new.mp4",
+                            "path": "/timelapse/new.mp4",
+                            "size": 20,
+                            "mtime": datetime(2020, 1, 1),
+                            "is_directory": False,
+                        },
+                    ],
+                    available=True,
+                )
+            return FileListResult(files=[], available=True)
+
+        with (
+            patch("backend.app.api.routes.archives.list_files_result_async", new=AsyncMock(side_effect=fake_list)),
+            patch("backend.app.api.routes.archives.ftps_handshake_blocked", return_value=False),
+        ):
+            response = await async_client.get(f"/api/v1/archives/{archive.id}/printer-media")
+
+        assert response.status_code == 200
+        timelapses = [item for item in response.json()["remote_files"] if item["kind"] == "timelapse"]
+        assert [item["name"] for item in timelapses] == ["new.mp4"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_printer_media_requires_printer_files_permission(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        tmp_path,
+        monkeypatch,
+    ):
+        printer = await printer_factory()
+        monkeypatch.setattr(settings, "base_dir", tmp_path)
+        timelapse = tmp_path / "timelapses" / "attached.mp4"
+        timelapse.parent.mkdir()
+        timelapse.write_bytes(b"attached video")
+        archive = await archive_factory(
+            printer.id,
+            started_at=datetime(2026, 8, 12, 10, 0),
+            timelapse_path="timelapses/attached.mp4",
+        )
+        setup = await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "mediaadmin",
+                "admin_password": "AdminPass1!",
+            },
+        )
+        assert setup.status_code == 200, setup.text
+        admin_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "mediaadmin", "password": "AdminPass1!"},
+        )
+        admin_headers = {"Authorization": f"Bearer {admin_login.json()['access_token']}"}
+        group = await async_client.post(
+            "/api/v1/groups/",
+            headers=admin_headers,
+            json={"name": "archive-only-media", "permissions": ["archives:read_all"]},
+        )
+        assert group.status_code == 201, group.text
+        user = await async_client.post(
+            "/api/v1/users/",
+            headers=admin_headers,
+            json={
+                "username": "archiveonlymedia",
+                "password": "ArchivePass1!",
+                "group_ids": [group.json()["id"]],
+            },
+        )
+        assert user.status_code == 201, user.text
+        login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "archiveonlymedia", "password": "ArchivePass1!"},
+        )
+
+        list_files = AsyncMock()
+        with patch("backend.app.api.routes.archives.list_files_result_async", new=list_files):
+            response = await async_client.get(
+                f"/api/v1/archives/{archive.id}/printer-media",
+                headers={"Authorization": f"Bearer {login.json()['access_token']}"},
+            )
+
+        assert response.status_code == 200
+        assert response.json()["local_timelapse"] == {"name": "attached.mp4", "size": 14}
+        assert response.json()["remote_files"] == []
+        assert response.json()["warnings"] == ["printer_files_forbidden"]
+        list_files.assert_not_awaited()
+
+        token_response = await async_client.post(
+            f"/api/v1/archives/{archive.id}/media-download-token",
+            headers={"Authorization": f"Bearer {login.json()['access_token']}"},
+        )
+        assert token_response.status_code == 200
+        download = await async_client.get(
+            f"/api/v1/archives/{archive.id}/media/dl/{token_response.json()['token']}/attached.mp4"
+        )
+        assert download.status_code == 200
+        assert download.content == b"attached video"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_media_token_is_archive_bound_single_use(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        tmp_path,
+        monkeypatch,
+    ):
+        printer = await printer_factory()
+        monkeypatch.setattr(settings, "base_dir", tmp_path)
+        media = tmp_path / "timelapses" / "bound.mp4"
+        media.parent.mkdir()
+        media.write_bytes(b"bound media")
+        archive_a = await archive_factory(printer.id, timelapse_path="timelapses/bound.mp4")
+        archive_b = await archive_factory(printer.id, timelapse_path="timelapses/bound.mp4")
+
+        minted = await async_client.post(f"/api/v1/archives/{archive_a.id}/media-download-token")
+        assert minted.status_code == 200
+        token = minted.json()["token"]
+        wrong = await async_client.get(f"/api/v1/archives/{archive_b.id}/media/dl/{token}/bound.mp4")
+        assert wrong.status_code == 403
+        correct = await async_client.get(f"/api/v1/archives/{archive_a.id}/media/dl/{token}/bound.mp4")
+        assert correct.status_code == 200
+        replay = await async_client.get(f"/api/v1/archives/{archive_a.id}/media/dl/{token}/bound.mp4")
+        assert replay.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_printer_media_enforces_api_key_printer_scope(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        db_session,
+    ):
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        printer_a = await printer_factory(name="Media Scope A", serial_number="MEDIASCOPEA00001")
+        printer_b = await printer_factory(name="Media Scope B", serial_number="MEDIASCOPEB00001")
+        archive = await archive_factory(
+            printer_b.id,
+            started_at=datetime(2026, 8, 12, 10, 0),
+            timelapse_path=None,
+        )
+        setup = await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "mediascopeadmin",
+                "admin_password": "AdminPass1!",
+            },
+        )
+        assert setup.status_code == 200, setup.text
+        full_key, key_hash, key_prefix = generate_api_key()
+        db_session.add(
+            APIKey(
+                name="media-printer-scope",
+                key_hash=key_hash,
+                key_prefix=key_prefix,
+                can_read_status=True,
+                can_control_printer=True,
+                printer_ids=[printer_a.id],
+                enabled=True,
+            )
+        )
+        await db_session.commit()
+
+        listing = AsyncMock()
+        with patch("backend.app.api.routes.archives.list_files_result_async", new=listing):
+            response = await async_client.get(
+                f"/api/v1/archives/{archive.id}/printer-media",
+                headers={"X-API-Key": full_key},
+            )
+
+        assert response.status_code == 403
+        listing.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_only_user_cannot_mint_archive_media_token(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        tmp_path,
+        monkeypatch,
+    ):
+        printer = await printer_factory()
+        monkeypatch.setattr(settings, "base_dir", tmp_path)
+        media = tmp_path / "timelapses" / "private.mp4"
+        media.parent.mkdir()
+        media.write_bytes(b"private")
+        archive = await archive_factory(printer.id, timelapse_path="timelapses/private.mp4")
+        setup = await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "cameraadmin",
+                "admin_password": "AdminPass1!",
+            },
+        )
+        assert setup.status_code == 200, setup.text
+        admin_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "cameraadmin", "password": "AdminPass1!"},
+        )
+        admin_headers = {"Authorization": f"Bearer {admin_login.json()['access_token']}"}
+        group = await async_client.post(
+            "/api/v1/groups/",
+            headers=admin_headers,
+            json={"name": "camera-only-media", "permissions": ["camera:view"]},
+        )
+        assert group.status_code == 201, group.text
+        user = await async_client.post(
+            "/api/v1/users/",
+            headers=admin_headers,
+            json={
+                "username": "cameraonlymedia",
+                "password": "CameraPass1!",
+                "group_ids": [group.json()["id"]],
+            },
+        )
+        assert user.status_code == 201, user.text
+        login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "cameraonlymedia", "password": "CameraPass1!"},
+        )
+
+        response = await async_client.post(
+            f"/api/v1/archives/{archive.id}/media-download-token",
+            headers={"Authorization": f"Bearer {login.json()['access_token']}"},
+        )
+
+        assert response.status_code == 403
+
     # ========================================================================
     # Update endpoints
     # ========================================================================

+ 232 - 2
backend/tests/integration/test_printers_api.py

@@ -10,6 +10,9 @@ import pytest
 from httpx import AsyncClient
 from sqlalchemy import select
 
+from backend.app.core.config import settings
+from backend.app.services.printer_media import PrinterFilesJobStatus, PrinterFilesZipResult
+
 
 @pytest.fixture(autouse=True)
 def _mock_printer_test_connection():
@@ -335,14 +338,26 @@ class TestPrintersAPI:
         filename: str,
         ascii_fallback: str,
         db_session,
+        tmp_path,
     ):
         """Non-ASCII filenames must not crash header encoding (issue #1245)."""
         printer = await printer_factory()
         file_bytes = b"fake 3mf content"
 
+        staged = tmp_path / "single" / "printer-file"
+        staged.parent.mkdir()
+        staged.write_bytes(file_bytes)
         with patch(
-            "backend.app.api.routes.printers.download_file_bytes_async",
-            new=AsyncMock(return_value=file_bytes),
+            "backend.app.api.routes.printers.build_printer_file",
+            new=AsyncMock(
+                return_value=PrinterFilesZipResult(
+                    path=staged,
+                    requested=1,
+                    successful=1,
+                    failed_paths=(),
+                    total_bytes=len(file_bytes),
+                )
+            ),
         ):
             response = await async_client.get(
                 f"/api/v1/printers/{printer.id}/files/download",
@@ -358,6 +373,221 @@ class TestPrintersAPI:
         encoded_name = content_disposition.split("filename*=UTF-8''", 1)[1]
         assert unquote(encoded_name) == filename
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_download_job_returns_immediately_and_prepared_token_streams_file_response(
+        self,
+        async_client: AsyncClient,
+        printer_factory,
+        tmp_path,
+        monkeypatch,
+    ):
+        """The browser polls a short job request, then uses a native GET."""
+        from backend.app.core.auth import create_slicer_download_token
+
+        printer = await printer_factory()
+        monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+        token = await create_slicer_download_token("printer-files", printer.id)
+        bundle_dir = settings.archive_dir / "temp" / "printer-file-downloads" / f"{printer.id}-{token}"
+        bundle_dir.mkdir(parents=True)
+        zip_path = bundle_dir / "printer-files.zip"
+        zip_path.write_bytes(b"disk-backed zip")
+
+        with patch(
+            "backend.app.api.routes.printers.start_printer_files_job",
+            new=AsyncMock(
+                return_value=PrinterFilesJobStatus(
+                    job_id="job-id",
+                    printer_id=printer.id,
+                    state="queued",
+                    requested=2,
+                    filename="Test Printer videos.zip",
+                )
+            ),
+        ):
+            job_response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/files/download-job",
+                json={
+                    "paths": ["/ipcam/one.mp4", "/ipcam/two.mp4"],
+                    "sizes": {"/ipcam/one.mp4": 7, "/ipcam/two.mp4": 8},
+                    "filename": "Test Printer videos.zip",
+                    "as_zip": True,
+                },
+            )
+        assert job_response.status_code == 200
+        assert job_response.json() == {
+            "job_id": "job-id",
+            "printer_id": printer.id,
+            "state": "queued",
+            "requested": 2,
+            "successful": 0,
+            "failed": 0,
+            "token": None,
+            "filename": "Test Printer videos.zip",
+            "message": None,
+        }
+
+        response = await async_client.get(
+            f"/api/v1/printers/{printer.id}/files/dl/{token}/Test%20Printer%20videos.zip",
+        )
+
+        assert response.status_code == 200
+        assert response.content == b"disk-backed zip"
+        assert 'filename="Test Printer videos.zip"' in response.headers["content-disposition"]
+
+        # Tokens are single-use, including after a successful large download.
+        replay = await async_client.get(
+            f"/api/v1/printers/{printer.id}/files/dl/{token}/Test%20Printer%20videos.zip",
+        )
+        assert replay.status_code == 403
+        # The browser reaches this URL through an <a download> click and saves
+        # whatever comes back under the name it was going to use, so a refusal
+        # has to arrive as a legible file rather than as a JSON body landing on
+        # the user's disk named .zip.
+        assert replay.headers["content-type"].startswith("text/plain")
+        assert 'filename="download-failed.txt"' in replay.headers["content-disposition"]
+        assert b"expired" in replay.content
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_download_token_rejects_another_printer_without_consuming_it(
+        self,
+        async_client: AsyncClient,
+        printer_factory,
+        tmp_path,
+        monkeypatch,
+    ):
+        """A printer-A token cannot download through printer B and survives that attempt."""
+        printer_a = await printer_factory(name="Printer A", serial_number="TOKENBINDINGA01")
+        printer_b = await printer_factory(name="Printer B", serial_number="TOKENBINDINGB01")
+        monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+        from backend.app.core.auth import create_slicer_download_token
+
+        token = await create_slicer_download_token("printer-files", printer_a.id)
+        bundle_dir = settings.archive_dir / "temp" / "printer-file-downloads" / f"{printer_a.id}-{token}"
+        bundle_dir.mkdir(parents=True)
+        zip_path = bundle_dir / "printer-files.zip"
+        zip_path.write_bytes(b"bound zip")
+
+        wrong_printer = await async_client.get(
+            f"/api/v1/printers/{printer_b.id}/files/dl/{token}/printer-files.zip",
+        )
+        assert wrong_printer.status_code == 403
+
+        correct_printer = await async_client.get(
+            f"/api/v1/printers/{printer_a.id}/files/dl/{token}/printer-files.zip",
+        )
+        assert correct_printer.status_code == 200
+        assert correct_printer.content == b"bound zip"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_legacy_bulk_download_accepts_paths_without_sizes(
+        self,
+        async_client: AsyncClient,
+        printer_factory,
+        tmp_path,
+    ):
+        """Existing API clients may omit the newer preflight size hints."""
+
+        printer = await printer_factory()
+        bundle_dir = tmp_path / "legacy-bundle"
+        bundle_dir.mkdir()
+        zip_path = bundle_dir / "printer-files.zip"
+        zip_path.write_bytes(b"legacy zip")
+        build_zip = AsyncMock(
+            return_value=PrinterFilesZipResult(
+                path=zip_path,
+                requested=2,
+                successful=2,
+                failed_paths=(),
+                total_bytes=10,
+            )
+        )
+
+        with patch("backend.app.api.routes.printers.build_printer_files_zip", new=build_zip):
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/files/download-zip",
+                json={"paths": ["model.gcode", "model.gcode"]},
+            )
+
+        assert response.status_code == 200
+        assert response.content == b"legacy zip"
+        assert build_zip.await_args.args[1:] == (["/model.gcode", "/model.gcode"], {})
+        assert build_zip.await_args.kwargs == {"preserve_paths": False, "allow_empty": True}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_legacy_bulk_download_empty_selection_keeps_400_contract(
+        self,
+        async_client: AsyncClient,
+        printer_factory,
+    ):
+        printer = await printer_factory()
+
+        response = await async_client.post(
+            f"/api/v1/printers/{printer.id}/files/download-zip",
+            json={"paths": []},
+        )
+
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("transport", ["header", "bearer"])
+    async def test_printer_file_routes_enforce_api_key_printer_scope(
+        self,
+        async_client: AsyncClient,
+        printer_factory,
+        db_session,
+        transport: str,
+    ):
+        """A key restricted to printer A must not list printer B's storage."""
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+        from backend.app.services.bambu_ftp import FileListResult
+
+        printer_a = await printer_factory(name="Scoped A", serial_number="SCOPEA000000001")
+        printer_b = await printer_factory(name="Scoped B", serial_number="SCOPEB000000001")
+        setup = await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "scopeadmin",
+                "admin_password": "AdminPass1!",
+            },
+        )
+        assert setup.status_code == 200, setup.text
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        db_session.add(
+            APIKey(
+                name=f"printer-scope-{transport}",
+                key_hash=key_hash,
+                key_prefix=key_prefix,
+                can_control_printer=True,
+                printer_ids=[printer_a.id],
+                enabled=True,
+            )
+        )
+        await db_session.commit()
+        headers = {"X-API-Key": full_key} if transport == "header" else {"Authorization": f"Bearer {full_key}"}
+
+        listing = AsyncMock(return_value=FileListResult(files=[], available=True))
+        with patch("backend.app.api.routes.printers.list_files_result_async", new=listing):
+            denied = await async_client.get(
+                f"/api/v1/printers/{printer_b.id}/files",
+                headers=headers,
+            )
+            allowed = await async_client.get(
+                f"/api/v1/printers/{printer_a.id}/files",
+                headers=headers,
+            )
+
+        assert denied.status_code == 403
+        assert allowed.status_code == 200
+        listing.assert_awaited_once()
+
     # ========================================================================
     # Status endpoint
     # ========================================================================

+ 123 - 6
backend/tests/unit/services/test_bambu_ftp.py

@@ -276,6 +276,66 @@ class TestDownload:
         assert not local.exists()
         client.disconnect()
 
+    def test_download_to_file_prefers_fresh_server_size_over_stale_client_hint(
+        self, ftp_client_factory, ftp_server, tmp_path
+    ):
+        """The immediate printer SIZE result overrides a stale browser hint."""
+        ftp_server.add_file("cache/short.bin", b"short")
+        local = tmp_path / "short.bin"
+        client = ftp_client_factory()
+        client.connect()
+        try:
+            assert client.download_to_file("/cache/short.bin", local, expected_size=100) is True
+            assert local.read_bytes() == b"short"
+        finally:
+            client.disconnect()
+
+    def test_download_to_file_enforces_actual_byte_limit(self, ftp_client_factory, ftp_server, tmp_path):
+        """Untrusted size hints cannot let a transfer exceed the server-side cap."""
+        ftp_server.add_file("cache/oversize.bin", b"too large")
+        local = tmp_path / "oversize.bin"
+        client = ftp_client_factory()
+        client.connect()
+        try:
+            with pytest.raises(bambu_ftp.DownloadLimitExceeded):
+                client.download_to_file("/cache/oversize.bin", local, max_bytes=3)
+            assert not local.exists()
+        finally:
+            client.disconnect()
+
+    def test_download_to_file_uses_server_size_when_client_hint_is_omitted(self, tmp_path):
+        """A clean short RETR is rejected using SIZE, without trusting a caller hint."""
+        local = tmp_path / "server-sized.bin"
+        client = BambuFTPClient("127.0.0.1", "12345678")
+
+        class FakeFTP:
+            def size(self, _remote_path):
+                return 100
+
+            def retrbinary(self, _command, callback):
+                callback(b"short")
+
+        client._ftp = FakeFTP()
+
+        assert client.download_to_file("/cache/server-sized.bin", local) is False
+        assert not local.exists()
+
+    def test_download_to_file_falls_back_to_client_hint_when_size_is_unsupported(self, tmp_path):
+        local = tmp_path / "hint-sized.bin"
+        client = BambuFTPClient("127.0.0.1", "12345678")
+
+        class FakeFTP:
+            def size(self, _remote_path):
+                raise bambu_ftp.ftplib.error_perm("502 SIZE unsupported")
+
+            def retrbinary(self, _command, callback):
+                callback(b"short")
+
+        client._ftp = FakeFTP()
+
+        assert client.download_to_file("/cache/hint-sized.bin", local, expected_size=100) is False
+        assert not local.exists()
+
     def test_download_to_file_missing_raises_not_on_printer(self, ftp_client_factory, tmp_path):
         """Missing file raises FileNotOnPrinterError so callers can short-circuit
         the retry loop — 550 means the file isn't there and retrying won't help."""
@@ -884,7 +944,7 @@ class TestAsyncWrappers:
             def connect(self):
                 return True
 
-            def download_to_file(self, remote_path, local_path):
+            def download_to_file(self, remote_path, local_path, **_kwargs):
                 time.sleep(0.4)  # longer than wait_for timeout=0.1
                 local_path.write_bytes(expected_content)
                 return True
@@ -933,12 +993,14 @@ class TestAsyncWrappers:
             def connect(self):
                 return True
 
-            def download_to_file(self, remote_path, local_path):
+            def download_to_file(self, remote_path, local_path, **kwargs):
                 # Simulate an in-progress partial write that never completes
-                # within the salvage grace period.
+                # until the async timeout asks the worker to unwind.
                 local_path.write_bytes(b"partial...")
-                time.sleep(2.0)
-                return True  # would complete eventually, but too late
+                while not kwargs["cancel_event"].is_set():
+                    time.sleep(0.01)
+                local_path.unlink(missing_ok=True)
+                raise bambu_ftp.DownloadCancelled(remote_path)
 
             def disconnect(self):
                 pass
@@ -990,7 +1052,7 @@ class TestAsyncWrappers:
             def connect(self):
                 return True
 
-            def download_to_file(self, remote_path, local_path):
+            def download_to_file(self, remote_path, local_path, **_kwargs):
                 time.sleep(1.5)  # wait_for times out at 1.0 s; zombie finishes 0.5 s later
                 local_path.write_bytes(expected_content)
                 return True
@@ -1014,6 +1076,61 @@ class TestAsyncWrappers:
         assert result is True
         assert local.read_bytes() == expected_content
 
+    @pytest.mark.asyncio
+    async def test_download_file_async_cancellation_waits_for_worker_cleanup(self, tmp_path, monkeypatch):
+        """Task cancellation returns only after the FTP worker has unwound."""
+        from backend.app.services import bambu_ftp
+
+        bambu_ftp.BambuFTPClient._mode_cache.pop("127.0.0.1", None)
+        local = tmp_path / "cancelled.bin"
+        started = threading.Event()
+        finished = threading.Event()
+
+        class FakeClient:
+            def __init__(self, *args, **kwargs):
+                pass
+
+            def connect(self):
+                return True
+
+            def download_to_file(self, remote_path, local_path, **kwargs):
+                local_path.write_bytes(b"partial")
+                started.set()
+                try:
+                    while not kwargs["cancel_event"].is_set():
+                        time.sleep(0.01)
+                    local_path.unlink(missing_ok=True)
+                    raise bambu_ftp.DownloadCancelled(remote_path)
+                finally:
+                    finished.set()
+
+            def disconnect(self):
+                pass
+
+        monkeypatch.setattr(bambu_ftp, "BambuFTPClient", FakeClient)
+        monkeypatch.setattr(FakeClient, "_mode_cache", {}, raising=False)
+        monkeypatch.setattr(FakeClient, "A1_MODELS", set(), raising=False)
+        monkeypatch.setattr(FakeClient, "cache_mode", staticmethod(lambda ip, mode: None), raising=False)
+
+        task = asyncio.create_task(
+            download_file_async(
+                "127.0.0.1",
+                "12345678",
+                "/cache/cancelled.bin",
+                local,
+                timeout=30.0,
+                printer_model="X1C",
+            )
+        )
+        assert await asyncio.to_thread(started.wait, 1.0)
+        task.cancel()
+
+        with pytest.raises(asyncio.CancelledError):
+            await task
+
+        assert finished.is_set()
+        assert not local.exists()
+
     @pytest.mark.asyncio
     async def test_download_file_try_paths_first_succeeds(self, patch_ftp_port, tmp_path):
         """download_file_try_paths_async succeeds on first path."""

+ 17 - 1
backend/tests/unit/test_http_utils.py

@@ -4,7 +4,7 @@ from urllib.parse import unquote
 
 import pytest
 
-from backend.app.utils.http import build_content_disposition
+from backend.app.utils.http import build_content_disposition, safe_download_filename
 
 
 @pytest.mark.parametrize(
@@ -69,3 +69,19 @@ def test_disposition_param_is_respected() -> None:
 def test_quotes_and_backslashes_stripped_from_ascii_fallback() -> None:
     header = build_content_disposition('a"b\\c.pdf')
     assert 'filename="abc.pdf"' in header
+
+
+def test_safe_download_filename_bounds_unicode_and_preserves_normal_suffixes() -> None:
+    filename = f"{'界' * 300}.gcode.3mf"
+    result = safe_download_filename(filename)
+
+    assert len(result) == 200
+    assert result.endswith(".gcode.3mf")
+
+
+def test_safe_download_filename_drops_path_control_chars_and_pathological_suffix() -> None:
+    result = safe_download_filename(f"../folder/bad\x00name.{'x' * 300}")
+
+    assert "/" not in result
+    assert "\x00" not in result
+    assert len(result) == 200

+ 411 - 0
backend/tests/unit/test_printer_media.py

@@ -0,0 +1,411 @@
+import ast
+import asyncio
+import os
+import shutil
+import threading
+import time
+import zipfile
+from datetime import datetime
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.core.config import settings
+from backend.app.services import printer_media
+from backend.app.services.printer_media import (
+    MAX_PRINTER_ZIP_BYTES,
+    PrinterFilesZipInsufficientSpaceError,
+    PrinterFilesZipTooLargeError,
+    build_printer_files_zip,
+    match_ipcam_chunks,
+    prune_stale_printer_file_bundles,
+    remove_printer_files_zip,
+)
+
+
+def test_module_imports_on_every_supported_platform():
+    """No POSIX-only import may sit at the top of this module.
+
+    Bambuddy ships a signed Windows installer, and printers.py imports this
+    module at startup, so a top-level ``import fcntl`` here is not a degraded
+    feature on Windows -- it is an application that does not boot at all.
+    network_utils.py is the house pattern: import inside the branch that needs
+    it, after checking ``sys.platform``.
+    """
+    tree = ast.parse(Path(printer_media.__file__).read_text(encoding="utf-8"))
+    top_level = {
+        alias.name.split(".")[0] for node in tree.body if isinstance(node, ast.Import) for alias in node.names
+    } | {node.module.split(".")[0] for node in tree.body if isinstance(node, ast.ImportFrom) and node.module}
+
+    assert not top_level & {"fcntl", "termios", "pwd", "grp", "resource", "syslog"}
+
+
+def test_match_ipcam_chunks_uses_archive_window_and_ignores_non_video_entries():
+    files = [
+        {"name": "index", "mtime": datetime(2026, 8, 12, 10, 5), "is_directory": False},
+        {"name": "ipcam-record.before.mp4", "mtime": datetime(2026, 8, 12, 9, 50), "is_directory": False},
+        {"name": "ipcam-record.first.mp4", "mtime": datetime(2026, 8, 12, 10, 4), "is_directory": False},
+        {"name": "ipcam-record.last.mp4", "mtime": datetime(2026, 8, 12, 11, 8), "is_directory": False},
+        {"name": "ipcam-record.after.mp4", "mtime": datetime(2026, 8, 12, 11, 11), "is_directory": False},
+    ]
+
+    matched = match_ipcam_chunks(
+        files,
+        datetime(2026, 8, 12, 10, 0),
+        datetime(2026, 8, 12, 11, 0),
+    )
+
+    assert [file["name"] for file in matched] == ["ipcam-record.first.mp4", "ipcam-record.last.mp4"]
+
+
+@pytest.mark.asyncio
+async def test_build_printer_files_zip_stages_on_data_volume_and_compresses_by_type(tmp_path, monkeypatch):
+    printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+
+    payloads = {
+        "/ipcam/chunk.mp4": (b"video-") * 512,
+        "/cache/model.gcode": (b"G1 X1 Y1\n") * 512,
+    }
+
+    async def fake_download(_ip, _code, remote_path, local_path: Path, **_kwargs):
+        local_path.write_bytes(payloads[remote_path])
+        return True
+
+    with patch(
+        "backend.app.services.printer_media.download_file_async",
+        new=AsyncMock(side_effect=fake_download),
+    ):
+        result = await build_printer_files_zip(
+            printer,
+            ["/ipcam/chunk.mp4", "/cache/model.gcode"],
+            {path: len(payload) for path, payload in payloads.items()},
+        )
+    zip_path = result.path
+
+    try:
+        assert result.successful == 2
+        assert zip_path.is_relative_to(settings.archive_dir / "temp" / "printer-file-downloads")
+        with zipfile.ZipFile(zip_path) as archive:
+            assert archive.namelist() == ["ipcam/chunk.mp4", "cache/model.gcode"]
+            assert archive.getinfo("ipcam/chunk.mp4").compress_type == zipfile.ZIP_STORED
+            assert archive.getinfo("cache/model.gcode").compress_type == zipfile.ZIP_DEFLATED
+        assert not list(zip_path.parent.glob("download-*"))
+    finally:
+        remove_printer_files_zip(zip_path)
+
+    assert not zip_path.parent.exists()
+
+
+@pytest.mark.asyncio
+async def test_build_printer_files_zip_offloads_blocking_zip_and_filesystem_work(tmp_path, monkeypatch):
+    printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    event_loop_thread = threading.get_ident()
+    offloaded_threads: list[int] = []
+    real_prune = printer_media._prune_stale_bundles
+    real_space_check = printer_media._check_initial_space
+
+    def tracking_prune(root):
+        offloaded_threads.append(threading.get_ident())
+        return real_prune(root)
+
+    def tracking_space_check(root, sizes):
+        offloaded_threads.append(threading.get_ident())
+        return real_space_check(root, sizes)
+
+    async def fake_download(_ip, _code, _remote_path, local_path: Path, **_kwargs):
+        local_path.write_bytes(b"G1 X1 Y1\n" * 512)
+        return True
+
+    monkeypatch.setattr(printer_media, "_prune_stale_bundles", tracking_prune)
+    monkeypatch.setattr(printer_media, "_check_initial_space", tracking_space_check)
+    with patch("backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=fake_download)):
+        result = await build_printer_files_zip(printer, ["/model.gcode"], {"/model.gcode": 4608})
+
+    try:
+        assert offloaded_threads
+        assert all(thread_id != event_loop_thread for thread_id in offloaded_threads)
+    finally:
+        remove_printer_files_zip(result.path)
+
+
+@pytest.mark.asyncio
+async def test_prune_stale_printer_file_bundles_removes_hour_old_abandoned_bundle(tmp_path, monkeypatch):
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    root = settings.archive_dir / "temp" / "printer-file-downloads"
+    stale = root / "stale"
+    fresh = root / "fresh"
+    stale.mkdir(parents=True)
+    fresh.mkdir()
+    (stale / "printer-files.zip").write_bytes(b"stale")
+    (fresh / "printer-files.zip").write_bytes(b"fresh")
+    old = time.time() - 60 * 60 - 1
+    os.utime(stale, (old, old))
+
+    await prune_stale_printer_file_bundles()
+
+    assert not stale.exists()
+    assert fresh.exists()
+
+
+@pytest.mark.asyncio
+async def test_build_printer_files_zip_skips_relative_and_nul_paths(tmp_path, monkeypatch):
+    printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+
+    async def fake_download(_ip, _code, remote_path, local_path: Path, **_kwargs):
+        local_path.write_bytes(b"valid")
+        return True
+
+    download = AsyncMock(side_effect=fake_download)
+    with patch("backend.app.services.printer_media.download_file_async", new=download):
+        result = await build_printer_files_zip(
+            printer,
+            ["relative.gcode", "/bad\x00.gcode", "/valid.gcode"],
+            {"relative.gcode": 1, "/bad\x00.gcode": 1, "/valid.gcode": 5},
+        )
+
+    try:
+        assert result.requested == 3
+        assert result.successful == 1
+        assert result.failed_paths == ("relative.gcode", "/bad\x00.gcode")
+        download.assert_awaited_once()
+        assert download.await_args.args[2] == "/valid.gcode"
+    finally:
+        remove_printer_files_zip(result.path)
+
+
+@pytest.mark.asyncio
+async def test_build_printer_files_zip_rejects_oversized_selection_before_download(tmp_path, monkeypatch):
+    printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    download = AsyncMock()
+
+    with (
+        patch("backend.app.services.printer_media.download_file_async", new=download),
+        pytest.raises(PrinterFilesZipTooLargeError),
+    ):
+        await build_printer_files_zip(
+            printer,
+            ["/huge.mp4"],
+            {"/huge.mp4": MAX_PRINTER_ZIP_BYTES + 1},
+        )
+
+    download.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_build_printer_files_zip_rejects_insufficient_data_volume_space(tmp_path, monkeypatch):
+    printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    monkeypatch.setattr("backend.app.services.printer_media.shutil.disk_usage", lambda _path: SimpleNamespace(free=1))
+
+    with pytest.raises(PrinterFilesZipInsufficientSpaceError):
+        await build_printer_files_zip(printer, ["/small.gcode"], {"/small.gcode": 5})
+
+
+@pytest.mark.asyncio
+async def test_build_printer_files_zip_keeps_a_file_whose_listing_size_went_stale(tmp_path, monkeypatch):
+    """A verified transfer is not re-judged against the browser's size hint.
+
+    download_to_file compares what it wrote against the printer's own SIZE and
+    treats that as the authority, precisely because it beats a hint the browser
+    round-tripped. The hint goes stale in the case this feature exists for -- an
+    /ipcam chunk still being written when the modal listed it -- and the file
+    then arrives longer than advertised. Dropping it as "truncated" would fail
+    the one selection the user came for; a genuinely short RETR is already
+    rejected a layer down (test_bambu_ftp.py).
+    """
+    printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+
+    async def fake_download(_ip, _code, _remote_path, local_path: Path, **_kwargs):
+        # The chunk grew by 20 bytes between the listing and the transfer.
+        local_path.write_bytes(b"A" * 120)
+        return True
+
+    with patch("backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=fake_download)):
+        result = await build_printer_files_zip(printer, ["/ipcam/chunk.mp4"], {"/ipcam/chunk.mp4": 100})
+
+    try:
+        assert (result.successful, result.failed_paths) == (1, ())
+        with zipfile.ZipFile(result.path) as archive:
+            assert archive.read("ipcam/chunk.mp4") == b"A" * 120
+    finally:
+        remove_printer_files_zip(result.path)
+
+
+def test_match_ipcam_chunks_caps_an_unfinished_archive_window():
+    files = [
+        {
+            "name": "ipcam-record.next-week.mp4",
+            "mtime": datetime(2026, 8, 20, 10, 0),
+            "is_directory": False,
+        }
+    ]
+
+    assert (
+        match_ipcam_chunks(
+            files,
+            datetime(2026, 8, 12, 10, 0),
+            None,
+            now=datetime(2026, 8, 21, 10, 0),
+        )
+        == []
+    )
+
+
+@pytest.mark.asyncio
+async def test_build_printer_files_zip_cleans_bundle_on_cancellation(tmp_path, monkeypatch):
+    printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+
+    async def cancel_download(*_args, **_kwargs):
+        raise asyncio.CancelledError
+
+    with (
+        patch("backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=cancel_download)),
+        pytest.raises(asyncio.CancelledError),
+    ):
+        await build_printer_files_zip(printer, ["/video.mp4"], {"/video.mp4": 100})
+
+    root = settings.archive_dir / "temp" / "printer-file-downloads"
+    assert not list(root.glob("bundle-*"))
+
+
+@pytest.mark.asyncio
+async def test_build_printer_files_zip_reports_per_file_progress(tmp_path, monkeypatch):
+    printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    progress: list[tuple[int, int]] = []
+
+    async def fake_download(_ip, _code, remote_path, local_path: Path, **_kwargs):
+        if remote_path.endswith("missing.gcode"):
+            return False
+        local_path.write_bytes(b"ok")
+        return True
+
+    async def report(successful: int, failed: int) -> None:
+        progress.append((successful, failed))
+
+    with patch(
+        "backend.app.services.printer_media.download_file_async",
+        new=AsyncMock(side_effect=fake_download),
+    ):
+        result = await build_printer_files_zip(
+            printer,
+            ["/ok.gcode", "/missing.gcode"],
+            {"/ok.gcode": 2, "/missing.gcode": 2},
+            progress_callback=report,
+        )
+
+    try:
+        assert progress == [(1, 0), (1, 1)]
+    finally:
+        remove_printer_files_zip(result.path)
+
+
+@pytest.mark.asyncio
+async def test_two_preparations_run_at_the_same_time(tmp_path, monkeypatch):
+    """Nothing queues one preparation behind another.
+
+    An exclusive staging lock held for the length of a transfer would make one
+    ten-gigabyte selection block every other download on the instance -- and the
+    same code path serves the file browser's 3MF preview, so it would block that
+    too, for as long as the selection takes.
+    """
+    printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    active = 0
+    max_active = 0
+
+    async def fake_download(_ip, _code, _remote_path, local_path: Path, **_kwargs):
+        nonlocal active, max_active
+        active += 1
+        max_active = max(max_active, active)
+        try:
+            await asyncio.sleep(0.05)
+            local_path.write_bytes(b"ok")
+            return True
+        finally:
+            active -= 1
+
+    with patch(
+        "backend.app.services.printer_media.download_file_async",
+        new=AsyncMock(side_effect=fake_download),
+    ):
+        first, second = await asyncio.gather(
+            build_printer_files_zip(printer, ["/first.gcode"], {"/first.gcode": 2}),
+            build_printer_files_zip(printer, ["/second.gcode"], {"/second.gcode": 2}),
+        )
+
+    try:
+        assert max_active == 2
+    finally:
+        remove_printer_files_zip(first.path)
+        remove_printer_files_zip(second.path)
+
+
+@pytest.mark.asyncio
+async def test_concurrent_preparations_both_stop_at_the_disk_reserve(tmp_path, monkeypatch):
+    """With preparations running together, the reserve is what has to hold.
+
+    The preflight only sees client-reported hints, and two jobs read the same
+    free space before either has spent any of it, so neither can be the bound.
+    The per-file check against actual bytes is, and it stops both of them
+    without leaving a staged bundle behind.
+    """
+    printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
+    archive_dir = tmp_path / "archive"
+    monkeypatch.setattr(settings, "archive_dir", archive_dir)
+    # Enough for the preflight, which is told 2 bytes; nowhere near enough for
+    # the 10 MiB that actually arrives.
+    monkeypatch.setattr(
+        "backend.app.services.printer_media.shutil.disk_usage",
+        lambda _path: SimpleNamespace(free=printer_media.PRINTER_ZIP_FREE_SPACE_RESERVE + 1024 * 1024),
+    )
+
+    async def fake_download(_ip, _code, _remote_path, local_path: Path, **_kwargs):
+        await asyncio.sleep(0.01)
+        local_path.write_bytes(b"A" * (10 * 1024 * 1024))
+        return True
+
+    with patch(
+        "backend.app.services.printer_media.download_file_async",
+        new=AsyncMock(side_effect=fake_download),
+    ):
+        outcomes = await asyncio.gather(
+            build_printer_files_zip(printer, ["/first.gcode"], {"/first.gcode": 2}),
+            build_printer_files_zip(printer, ["/second.gcode"], {"/second.gcode": 2}),
+            return_exceptions=True,
+        )
+
+    assert all(isinstance(outcome, PrinterFilesZipInsufficientSpaceError) for outcome in outcomes), outcomes
+    root = archive_dir / "temp" / "printer-file-downloads"
+    assert [child for child in root.iterdir() if child.is_dir()] == []
+
+
+@pytest.mark.asyncio
+async def test_shutdown_awaits_download_jobs_and_publishes_cancellation(tmp_path, monkeypatch):
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    job_id = "shutdown-job-abcdefghijklmnop"
+    printer_media._ensure_printer_zip_root()
+    started = asyncio.Event()
+
+    async def wait_forever():
+        started.set()
+        await asyncio.Event().wait()
+
+    task = asyncio.create_task(wait_forever())
+    printer_media._LOCAL_JOB_TASKS[job_id] = task
+    await started.wait()
+
+    await printer_media.stop_printer_download_cleanup()
+
+    assert task.done()
+    assert task.cancelled()
+    assert printer_media._LOCAL_JOB_TASKS == {}
+    assert printer_media._job_cancel_path(job_id).exists()

+ 6 - 0
backend/tests/unit/test_route_auth_coverage.py

@@ -81,6 +81,12 @@ _PUBLIC_ROUTES: frozenset[tuple[str, str]] = frozenset(
         ("GET", "/api/v1/archives/{archive_id}/dl/{token}/{filename}"),
         ("GET", "/api/v1/archives/{archive_id}/source-dl/{token}/{filename}"),
         ("GET", "/api/v1/library/files/{file_id}/dl/{token}/{filename}"),
+        # Printer download target — validates a short-lived, single-use token
+        # bound to the printer ID before streaming its prepared bundle.
+        ("GET", "/api/v1/printers/{printer_id}/files/dl/{token}/{filename}"),
+        # Attached archive media target — validates a token bound to the
+        # archive ID before returning the local timelapse.
+        ("GET", "/api/v1/archives/{archive_id}/media/dl/{token}/{filename}"),
         # Obico cached frame — one-time nonce embedded in <img> tags.
         ("GET", "/api/v1/obico/cached-frame/{nonce}"),
         # MakerWorld thumbnail proxy — fetches external URL; no Bambuddy data exposed.

+ 199 - 0
frontend/src/__tests__/components/ArchiveMediaDownloadModal.test.tsx

@@ -0,0 +1,199 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ArchiveMediaDownloadModal } from '../../components/ArchiveMediaDownloadModal';
+import { api } from '../../api/client';
+
+const showToast = vi.fn();
+
+vi.mock('../../contexts/ToastContext', () => ({
+  useToast: () => ({ showToast }),
+}));
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getArchivePrinterMedia: vi.fn(),
+    downloadArchiveTimelapse: vi.fn(),
+    downloadPrinterFilesAsZip: vi.fn(),
+  },
+}));
+
+const media = {
+  archive_id: 1,
+  printer_id: 1,
+  local_timelapse: null,
+  remote_files: [{
+    name: 'video.mp4',
+    path: '/timelapse/video.mp4',
+    size: 1024,
+    mtime: '2026-08-18T10:00:00Z',
+    kind: 'timelapse' as const,
+  }],
+  warnings: [],
+};
+
+describe('ArchiveMediaDownloadModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(api.getArchivePrinterMedia).mockResolvedValue(media);
+  });
+
+  it('does not reselect a manually deselected single file when query data refreshes', async () => {
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    render(
+      <QueryClientProvider client={queryClient}>
+        <ArchiveMediaDownloadModal
+          archiveId={1}
+          archiveName="Test print"
+          printerName="Printer"
+          onClose={vi.fn()}
+        />
+      </QueryClientProvider>,
+    );
+
+    const filename = await screen.findByText('video.mp4');
+    const downloadButton = screen.getByRole('button', { name: /Download selected \(1\)/i });
+    expect(downloadButton).toBeEnabled();
+
+    fireEvent.click(filename.closest('button')!);
+    expect(screen.getByRole('button', { name: /Download selected \(0\)/i })).toBeDisabled();
+
+    await act(async () => {
+      queryClient.setQueryData(['archive-printer-media', 1], {
+        ...media,
+        remote_files: [...media.remote_files],
+      });
+    });
+
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: /Download selected \(0\)/i })).toBeDisabled();
+    });
+  });
+
+  it('prunes a selected path that disappears during refetch', async () => {
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    render(
+      <QueryClientProvider client={queryClient}>
+        <ArchiveMediaDownloadModal
+          archiveId={1}
+          archiveName="Test print"
+          printerName="Printer"
+          onClose={vi.fn()}
+        />
+      </QueryClientProvider>,
+    );
+
+    expect(await screen.findByRole('button', { name: /Download selected \(1\)/i })).toBeEnabled();
+    await act(async () => {
+      queryClient.setQueryData(['archive-printer-media', 1], { ...media, remote_files: [] });
+    });
+
+    await waitFor(() => expect(screen.queryByRole('button', { name: /Download selected/i })).not.toBeInTheDocument());
+  });
+
+  it('shows unavailable warnings even when no media was found', async () => {
+    vi.mocked(api.getArchivePrinterMedia).mockResolvedValue({
+      ...media,
+      remote_files: [],
+      warnings: ['timelapse_unavailable', 'ipcam_unavailable'],
+    });
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    render(
+      <QueryClientProvider client={queryClient}>
+        <ArchiveMediaDownloadModal
+          archiveId={1}
+          archiveName="Test print"
+          printerName="Printer"
+          onClose={vi.fn()}
+        />
+      </QueryClientProvider>,
+    );
+
+    expect(await screen.findByText(/No timelapse or IP camera chunks were found/)).toBeInTheDocument();
+    expect(screen.getByText('The timelapse directory could not be read')).toBeInTheDocument();
+    expect(screen.getByText('The IP camera directory could not be read')).toBeInTheDocument();
+  });
+
+  it('shows how far the preparation has got', async () => {
+    // The file browser has shown per-file progress from this same call all
+    // along; without it the archive side is a spinner for as long as the
+    // transfer takes, which for a few /ipcam chunks is minutes.
+    vi.mocked(api.downloadPrinterFilesAsZip).mockImplementation(
+      (_printerId, _paths, _sizes, _filename, _asZip, _signal, onProgress) => {
+        onProgress?.(1, 2);
+        return new Promise(() => {});
+      },
+    );
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    render(
+      <QueryClientProvider client={queryClient}>
+        <ArchiveMediaDownloadModal
+          archiveId={1}
+          archiveName="Test print"
+          printerName="Printer"
+          onClose={vi.fn()}
+        />
+      </QueryClientProvider>,
+    );
+
+    fireEvent.click(await screen.findByRole('button', { name: /Download selected \(1\)/i }));
+
+    expect(await screen.findByText('1/2')).toBeInTheDocument();
+  });
+
+  it('cancels an in-flight printer preparation when the modal unmounts', async () => {
+    let observedSignal: AbortSignal | undefined;
+    vi.mocked(api.downloadPrinterFilesAsZip).mockImplementation(
+      (_printerId, _paths, _sizes, _filename, _asZip, signal) => {
+        observedSignal = signal;
+        return new Promise((_resolve, reject) => {
+          signal?.addEventListener('abort', () => reject(new DOMException('cancelled', 'AbortError')));
+        });
+      },
+    );
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    const { unmount } = render(
+      <QueryClientProvider client={queryClient}>
+        <ArchiveMediaDownloadModal
+          archiveId={1}
+          archiveName="Test print"
+          printerName="Printer"
+          onClose={vi.fn()}
+        />
+      </QueryClientProvider>,
+    );
+
+    fireEvent.click(await screen.findByRole('button', { name: /Download selected \(1\)/i }));
+    await waitFor(() => expect(api.downloadPrinterFilesAsZip).toHaveBeenCalledOnce());
+    unmount();
+
+    expect(observedSignal?.aborted).toBe(true);
+  });
+
+  it('reports a local timelapse token failure', async () => {
+    vi.mocked(api.getArchivePrinterMedia).mockResolvedValue({
+      ...media,
+      local_timelapse: { name: 'attached.mp4', size: 42 },
+      remote_files: [],
+    });
+    vi.mocked(api.downloadArchiveTimelapse).mockRejectedValue(new Error('token expired'));
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    render(
+      <QueryClientProvider client={queryClient}>
+        <ArchiveMediaDownloadModal
+          archiveId={1}
+          archiveName="Test print"
+          printerName="Printer"
+          onClose={vi.fn()}
+        />
+      </QueryClientProvider>,
+    );
+
+    fireEvent.click(await screen.findByRole('button', { name: /^Download$/i }));
+
+    await waitFor(() => expect(showToast).toHaveBeenCalledWith(
+      'Download failed: token expired',
+      'error',
+    ));
+  });
+});

+ 185 - 1
frontend/src/__tests__/components/FileManagerModal.test.tsx

@@ -219,6 +219,64 @@ describe('FileManagerModal', () => {
       }
     });
 
+    it('selects the visible range between a click and a shift-click', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/files', () => HttpResponse.json({
+          files: [
+            ...mockFiles.filter(file => file.is_directory),
+            { name: 'alpha.gcode', path: '/alpha.gcode', size: 1, is_directory: false, mtime: null },
+            { name: 'bravo.gcode', path: '/bravo.gcode', size: 2, is_directory: false, mtime: null },
+            { name: 'charlie.gcode', path: '/charlie.gcode', size: 3, is_directory: false, mtime: null },
+            { name: 'delta.gcode', path: '/delta.gcode', size: 4, is_directory: false, mtime: null },
+          ],
+        })),
+      );
+
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      fireEvent.click(await screen.findByRole('button', { name: 'Select alpha.gcode' }));
+      fireEvent.click(screen.getByRole('button', { name: 'Select charlie.gcode' }), { shiftKey: true });
+
+      expect(await screen.findByText('3 selected')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Deselect alpha.gcode' })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Deselect bravo.gcode' })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Deselect charlie.gcode' })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Select delta.gcode' })).toBeInTheDocument();
+    });
+
+    it('always selects a shift-clicked range even when the target is already selected', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/files', () => HttpResponse.json({
+          files: [
+            { name: 'alpha.gcode', path: '/alpha.gcode', size: 1, is_directory: false, mtime: null },
+            { name: 'bravo.gcode', path: '/bravo.gcode', size: 2, is_directory: false, mtime: null },
+            { name: 'charlie.gcode', path: '/charlie.gcode', size: 3, is_directory: false, mtime: null },
+          ],
+        })),
+      );
+
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      fireEvent.click(await screen.findByRole('button', { name: 'Select alpha.gcode' }));
+      fireEvent.click(screen.getByRole('button', { name: 'Select charlie.gcode' }));
+      fireEvent.click(screen.getByRole('button', { name: 'Deselect alpha.gcode' }), { shiftKey: true });
+
+      expect(await screen.findByText('3 selected')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Deselect bravo.gcode' })).toBeInTheDocument();
+    });
+
     it('enables download button when files are selected', async () => {
       render(
         <FileManagerModal
@@ -250,6 +308,58 @@ describe('FileManagerModal', () => {
         expect(screen.getByText('Select All')).toBeInTheDocument();
       });
     });
+
+    it('starts a preparation job, uses a native download link, and reports partial results', async () => {
+      let preparation: {
+        paths: string[];
+        sizes: Record<string, number>;
+        filename: string;
+        as_zip: boolean;
+      } | null = null;
+      server.use(
+        http.post('/api/v1/printers/:id/files/download-job', async ({ request }) => {
+          preparation = await request.json() as typeof preparation;
+          return HttpResponse.json({
+            job_id: 'job-id',
+            state: 'ready',
+            token: 'download-token',
+            requested: 2,
+            successful: 1,
+            failed: 1,
+            message: null,
+          });
+        }),
+      );
+      const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
+
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => expect(screen.getByText('Select All')).toBeInTheDocument());
+      fireEvent.click(screen.getByText('Select All'));
+      fireEvent.click(screen.getByRole('button', { name: /Download \(2\)/i }));
+
+      await waitFor(() => expect(clickSpy).toHaveBeenCalledOnce());
+      expect(preparation).toEqual({
+        paths: ['/benchy.3mf', '/print_job.gcode'],
+        sizes: {
+          '/benchy.3mf': 1048575,
+          '/print_job.gcode': 2048000,
+        },
+        filename: 'X1_Carbon-files.zip',
+        as_zip: true,
+      });
+      expect(document.querySelector('a')).toBeNull();
+      expect(await screen.findByText(
+        'ZIP download started with 1 of 2 files; the rest could not be retrieved',
+      )).toBeInTheDocument();
+      clickSpy.mockRestore();
+    });
   });
 
   describe('search and filter', () => {
@@ -286,6 +396,59 @@ describe('FileManagerModal', () => {
         expect(screen.queryByText('print_job.gcode')).not.toBeInTheDocument();
       });
     });
+
+    it('selects all files from the shared visible-file filter', async () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => expect(screen.getByText('benchy.3mf')).toBeInTheDocument());
+      fireEvent.change(screen.getByPlaceholderText('Filter files...'), { target: { value: 'benchy' } });
+      fireEvent.click(screen.getByText('Select All'));
+
+      expect(screen.getByText('1 selected')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Download' })).toBeEnabled();
+    });
+
+    it('keeps the selection when a refresh cannot reach the printer', async () => {
+      render(
+        <FileManagerModal printerId={1} printerName="X1 Carbon" onClose={mockOnClose} />
+      );
+
+      fireEvent.click(await screen.findByRole('button', { name: 'Select benchy.3mf' }));
+      expect(screen.getByText('1 selected')).toBeInTheDocument();
+
+      // An unreachable printer answers with an empty list plus a warning. That
+      // is not the same statement as "those files are gone", and it must not
+      // throw away a selection the user made moments ago.
+      server.use(
+        http.get('/api/v1/printers/:id/files', () =>
+          HttpResponse.json({ files: [], warnings: ['printer_unavailable'] })
+        )
+      );
+      fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
+
+      expect(await screen.findByText(
+        'The printer file service is unavailable. Try again when the printer is reachable.',
+      )).toBeInTheDocument();
+      expect(screen.getByText('1 selected')).toBeInTheDocument();
+    });
+
+    it('drops hidden selections when the filter changes', async () => {
+      render(
+        <FileManagerModal printerId={1} printerName="X1 Carbon" onClose={mockOnClose} />
+      );
+
+      fireEvent.click(await screen.findByRole('button', { name: 'Select benchy.3mf' }));
+      expect(screen.getByText('1 selected')).toBeInTheDocument();
+      fireEvent.change(screen.getByPlaceholderText('Filter files...'), { target: { value: 'gcode' } });
+
+      await waitFor(() => expect(screen.getByRole('button', { name: 'Download' })).toBeDisabled());
+    });
   });
 
   describe('sorting', () => {
@@ -386,9 +549,30 @@ describe('FileManagerModal', () => {
       );
 
       await waitFor(() => {
-        expect(screen.getByText('No files in this directory')).toBeInTheDocument();
+          expect(screen.getByText('No files on printer')).toBeInTheDocument();
       });
     });
+
+    it('distinguishes an unreachable printer from an empty directory', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/files', () => {
+          return HttpResponse.json({ files: [], warnings: ['printer_unavailable'] });
+        })
+      );
+
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(await screen.findByText(
+        'The printer file service is unavailable. Try again when the printer is reachable.',
+      )).toBeInTheDocument();
+      expect(screen.queryByText('No files on printer')).not.toBeInTheDocument();
+    });
   });
 
   describe('loading state', () => {

+ 110 - 1
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -2,12 +2,13 @@
  * Tests for the ArchivesPage component.
  */
 
-import { describe, it, expect, beforeEach } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
 import { screen, waitFor, fireEvent } from '@testing-library/react';
 import { render } from '../utils';
 import { ArchivesPage } from '../../pages/ArchivesPage';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
+import { setAuthToken } from '../../api/client';
 
 const mockArchives = [
   {
@@ -68,6 +69,7 @@ const mockArchiveStats = {
 
 describe('ArchivesPage', () => {
   beforeEach(() => {
+    setAuthToken(null);
     server.use(
       http.get('/api/v1/archives/', () => {
         return HttpResponse.json(mockArchives);
@@ -102,6 +104,10 @@ describe('ArchivesPage', () => {
     );
   });
 
+  afterEach(() => {
+    setAuthToken(null);
+  });
+
   describe('rendering', () => {
     it('renders the page title', async () => {
       render(<ArchivesPage />);
@@ -340,6 +346,109 @@ describe('ArchivesPage', () => {
   });
 
   describe('timelapse management', () => {
+    it('keeps an attached timelapse available without printer-file permission', async () => {
+      setAuthToken('archive-only-token', 'session');
+      server.use(
+        http.get('*/api/v1/auth/status', () => HttpResponse.json({
+          auth_enabled: true,
+          requires_setup: false,
+        })),
+        http.get('/api/v1/auth/me', () => HttpResponse.json({
+          id: 7,
+          username: 'archive-viewer',
+          is_active: true,
+          is_admin: false,
+          groups: [],
+          permissions: ['archives:read_all'],
+          created_at: '2026-08-18T00:00:00Z',
+        })),
+        http.get('/api/v1/archives/', () => HttpResponse.json([
+          { ...mockArchives[0], timelapse_path: 'timelapses/attached.mp4' },
+          { ...mockArchives[1], timelapse_path: null },
+        ])),
+        http.get('/api/v1/archives/:id/printer-media', ({ params }) => HttpResponse.json({
+          archive_id: Number(params.id),
+          printer_id: 1,
+          local_timelapse: { name: 'attached.mp4', size: 1024 },
+          remote_files: [],
+          warnings: ['printer_files_forbidden'],
+        })),
+      );
+
+      render(<ArchivesPage />);
+
+      const mediaButton = (await screen.findAllByTitle('Download print videos'))[0];
+      expect(mediaButton).toBeEnabled();
+      const deniedButtons = screen.getAllByTitle('You do not have permission to access printer files');
+      expect(deniedButtons.every(button => button.hasAttribute('disabled'))).toBe(true);
+
+      fireEvent.click(mediaButton);
+      expect(await screen.findByText('attached.mp4')).toBeInTheDocument();
+      expect(screen.getByText('You do not have permission to access printer files')).toBeInTheDocument();
+    });
+
+    it('opens print video downloads and shows matching IP camera chunks', async () => {
+      server.use(
+        http.get('/api/v1/archives/:id/printer-media', ({ params }) => {
+          return HttpResponse.json({
+            archive_id: Number(params.id),
+            printer_id: 1,
+            local_timelapse: null,
+            remote_files: [
+              {
+                name: 'ipcam-record.2024-01-01_10-05-00.1.mp4',
+                path: '/ipcam/ipcam-record.2024-01-01_10-05-00.1.mp4',
+                size: 250_000_000,
+                mtime: '2024-01-01T10:10:00Z',
+                kind: 'ipcam',
+              },
+            ],
+            warnings: [],
+          });
+        }),
+      );
+
+      render(<ArchivesPage />);
+      const mediaButtons = await screen.findAllByTitle('Download print videos');
+      fireEvent.click(mediaButtons[0]);
+
+      expect(await screen.findByText('Print videos')).toBeInTheDocument();
+      expect(await screen.findByText('ipcam-record.2024-01-01_10-05-00.1.mp4')).toBeInTheDocument();
+    });
+
+    it('shows a toast when printer video ZIP preparation fails', async () => {
+      server.use(
+        http.get('/api/v1/archives/:id/printer-media', ({ params }) => HttpResponse.json({
+          archive_id: Number(params.id),
+          printer_id: 1,
+          local_timelapse: null,
+          remote_files: [{
+            name: 'ipcam-record.1.mp4',
+            path: '/ipcam/ipcam-record.1.mp4',
+            size: 250_000_000,
+            mtime: '2024-01-01T10:10:00Z',
+            kind: 'ipcam',
+          }],
+          warnings: [],
+        })),
+        http.post('/api/v1/printers/:id/files/download-job', () => HttpResponse.json({
+          job_id: 'failed-job',
+          state: 'failed',
+          requested: 1,
+          successful: 0,
+          failed: 0,
+          token: null,
+          message: 'Not enough app data volume space',
+        })),
+      );
+
+      render(<ArchivesPage />);
+      fireEvent.click((await screen.findAllByTitle('Download print videos'))[0]);
+      fireEvent.click(await screen.findByRole('button', { name: /Download selected \(1\)/i }));
+
+      expect(await screen.findByText('Download failed: Not enough app data volume space')).toBeInTheDocument();
+    });
+
     it('shows upload timelapse menu item when no timelapse attached', async () => {
       const archivesWithoutTimelapse = mockArchives.map(a => ({ ...a, timelapse_path: null }));
       server.use(

+ 16 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -150,6 +150,22 @@ describe('PrintersPage', () => {
         expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
       });
     });
+
+    it('offers FTP file browsing when MQTT status is offline', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, connected: false });
+        }),
+      );
+
+      render(<PrintersPage />);
+
+      const browseButtons = await screen.findAllByRole('button', { name: /browse printer files/i });
+      expect(browseButtons).toHaveLength(mockPrinters.length);
+      await userEvent.click(browseButtons[0]);
+
+      expect(await screen.findByText('File Manager')).toBeInTheDocument();
+    });
   });
 
   describe('printer info', () => {

+ 6 - 3
frontend/src/__tests__/pages/PrintersPageDropOnBusy.test.tsx

@@ -58,9 +58,12 @@ function renderWith(statusOver: Record<string, unknown>) {
     http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
     http.get('/api/v1/printers/:id/status', () => HttpResponse.json(makeStatus(statusOver))),
     http.get('/api/v1/queue/', () => HttpResponse.json([])),
-    http.post('/api/v1/library/files', async ({ request }) => {
-      const form = await request.formData();
-      uploads.push((form.get('file') as File).name);
+    http.post('/api/v1/library/files', () => {
+      // Parsing multipart bodies in MSW depends on the Node.js FormData
+      // implementation and is unrelated to this regression. Recording the
+      // matched request proves the page attempted the upload without making
+      // the test sensitive to that runtime detail.
+      uploads.push('request');
       return HttpResponse.json({ id: 7, filename: 'part.gcode', metadata: {} });
     }),
   );

+ 105 - 39
frontend/src/api/client.ts

@@ -696,6 +696,22 @@ export interface ArchiveDuplicate {
   match_type: 'exact' | 'similar';  // 'exact' = hash match, 'similar' = name match
 }
 
+export interface ArchivePrinterMediaFile {
+  name: string;
+  path: string;
+  size: number;
+  mtime: string | null;
+  kind: 'timelapse' | 'ipcam';
+}
+
+export interface ArchivePrinterMedia {
+  archive_id: number;
+  printer_id: number | null;
+  local_timelapse: { name: string; size: number } | null;
+  remote_files: ArchivePrinterMediaFile[];
+  warnings: Array<'printer_missing' | 'timelapse_unavailable' | 'ipcam_unavailable' | 'printer_files_forbidden'>;
+}
+
 export interface Archive {
   id: number;
   printer_id: number | null;
@@ -4671,6 +4687,7 @@ export const api = {
         path: string;
         mtime?: string;
       }>;
+      warnings: Array<'printer_unavailable'>;
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
     `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
@@ -4701,46 +4718,81 @@ export const api = {
     }>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),
   getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>
     withStreamToken(`${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`),
-  downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
-    const headers: Record<string, string> = {};
-    if (authToken) {
-      headers['Authorization'] = `Bearer ${authToken}`;
-    }
-    const response = await fetch(
-      `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
-      { headers }
-    );
-    if (!response.ok) {
-      const error = await response.json().catch(() => ({}));
-      throw new Error(error.detail || `HTTP ${response.status}`);
-    }
-    const disposition = response.headers.get('Content-Disposition');
-    const filename = parseContentDispositionFilename(disposition) || path.split('/').pop() || 'download';
-    const blob = await response.blob();
-    const url = window.URL.createObjectURL(blob);
-    const a = document.createElement('a');
-    a.href = url;
-    a.download = filename;
-    document.body.appendChild(a);
-    a.click();
-    document.body.removeChild(a);
-    window.URL.revokeObjectURL(url);
-  },
-  downloadPrinterFilesAsZip: async (printerId: number, paths: string[]): Promise<Blob> => {
-    const headers: Record<string, string> = { 'Content-Type': 'application/json' };
-    if (authToken) {
-      headers['Authorization'] = `Bearer ${authToken}`;
-    }
-    const response = await fetch(`${API_BASE}/printers/${printerId}/files/download-zip`, {
-      method: 'POST',
-      headers,
-      body: JSON.stringify({ paths }),
-    });
-    if (!response.ok) {
-      const error = await response.json().catch(() => ({}));
-      throw new Error(error.detail || `HTTP ${response.status}`);
+  downloadPrinterFilesAsZip: async (
+    printerId: number,
+    paths: string[],
+    sizes: Record<string, number>,
+    filename = 'printer-files.zip',
+    asZip = true,
+    signal?: AbortSignal,
+    onProgress?: (completed: number, total: number) => void,
+  ): Promise<{ requested: number; successful: number; failed: number }> => {
+    type JobStatus = {
+      job_id: string;
+      state: 'queued' | 'preparing' | 'ready' | 'failed' | 'cancelled';
+      requested: number;
+      successful: number;
+      failed: number;
+      token: string | null;
+      message: string | null;
+    };
+    let jobId: string | null = null;
+    try {
+      if (signal?.aborted) throw new DOMException('Download cancelled', 'AbortError');
+      let status = await request<JobStatus>(`/printers/${printerId}/files/download-job`, {
+        method: 'POST',
+        body: JSON.stringify({ paths, sizes, filename, as_zip: asZip }),
+        signal,
+      });
+      jobId = status.job_id;
+      let polls = 0;
+      while (status.state === 'queued' || status.state === 'preparing') {
+        onProgress?.(status.successful + status.failed, status.requested);
+        // Small selections finish in the first seconds, so poll quickly there.
+        // A large one runs for up to half an hour, where half-second polling is
+        // thousands of requests that each re-check the caller's credentials.
+        const delay = polls < 10 ? 500 : 2000;
+        polls += 1;
+        await new Promise<void>((resolve, reject) => {
+          if (signal?.aborted) {
+            reject(new DOMException('Download cancelled', 'AbortError'));
+            return;
+          }
+          const onAbort = () => {
+            window.clearTimeout(timer);
+            reject(new DOMException('Download cancelled', 'AbortError'));
+          };
+          const timer = window.setTimeout(() => {
+            signal?.removeEventListener('abort', onAbort);
+            resolve();
+          }, delay);
+          signal?.addEventListener('abort', onAbort, { once: true });
+        });
+        status = await request<JobStatus>(`/printers/${printerId}/files/download-jobs/${jobId}`, { signal });
+      }
+      onProgress?.(status.successful + status.failed, status.requested);
+      if (status.state !== 'ready' || !status.token) {
+        throw new Error(status.message || (status.state === 'cancelled'
+          ? 'Download cancelled'
+          : 'Printer download preparation failed'));
+      }
+      const link = document.createElement('a');
+      link.href = `${API_BASE}/printers/${printerId}/files/dl/${encodeURIComponent(status.token)}/${encodeURIComponent(filename)}`;
+      link.download = filename;
+      document.body.appendChild(link);
+      link.click();
+      link.remove();
+      return {
+        requested: status.requested,
+        successful: status.successful,
+        failed: status.failed,
+      };
+    } catch (error) {
+      if (jobId) {
+        await request(`/printers/${printerId}/files/download-jobs/${jobId}`, { method: 'DELETE' }).catch(() => undefined);
+      }
+      throw error;
     }
-    return response.blob();
   },
   deletePrinterFile: (printerId: number, path: string) =>
     request<{ status: string; path: string }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`, {
@@ -4994,6 +5046,20 @@ export const api = {
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
   getArchivePlatePreview: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/plate-preview`),
   getArchiveTimelapse: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`),
+  downloadArchiveTimelapse: async (id: number, filename: string): Promise<void> => {
+    const prepared = await request<{ token: string; filename: string }>(
+      `/archives/${id}/media-download-token`,
+      { method: 'POST' },
+    );
+    const link = document.createElement('a');
+    link.href = `${API_BASE}/archives/${id}/media/dl/${encodeURIComponent(prepared.token)}/${encodeURIComponent(filename)}`;
+    link.download = filename;
+    document.body.appendChild(link);
+    link.click();
+    link.remove();
+  },
+  getArchivePrinterMedia: (id: number) =>
+    request<ArchivePrinterMedia>(`/archives/${id}/printer-media`),
   scanArchiveTimelapse: (id: number) =>
     request<{
       status: string;

+ 256 - 0
frontend/src/components/ArchiveMediaDownloadModal.tsx

@@ -0,0 +1,256 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { CheckSquare, Download, Film, Loader2, Square, Video, X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { api } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+import { formatFileSize } from '../utils/file';
+import { Button } from './Button';
+
+interface ArchiveMediaDownloadModalProps {
+  archiveId: number;
+  archiveName: string;
+  printerName: string;
+  onClose: () => void;
+}
+
+export function ArchiveMediaDownloadModal({
+  archiveId,
+  archiveName,
+  printerName,
+  onClose,
+}: ArchiveMediaDownloadModalProps) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
+  const [downloadStarting, setDownloadStarting] = useState(false);
+  const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);
+  const initializedSelectionArchiveRef = useRef<number | null>(null);
+  const downloadAbortRef = useRef<AbortController | null>(null);
+  const mediaQuery = useQuery({
+    queryKey: ['archive-printer-media', archiveId],
+    queryFn: () => api.getArchivePrinterMedia(archiveId),
+    staleTime: 60_000,
+  });
+
+  const remoteFiles = useMemo(() => mediaQuery.data?.remote_files ?? [], [mediaQuery.data]);
+
+  useEffect(() => {
+    if (!mediaQuery.data || initializedSelectionArchiveRef.current === archiveId) return;
+    initializedSelectionArchiveRef.current = archiveId;
+    if (remoteFiles.length === 1) {
+      setSelectedPaths(new Set([remoteFiles[0].path]));
+    }
+  }, [archiveId, mediaQuery.data, remoteFiles]);
+
+  useEffect(() => {
+    const availablePaths = new Set(remoteFiles.map(file => file.path));
+    setSelectedPaths(current => new Set([...current].filter(path => availablePaths.has(path))));
+  }, [remoteFiles]);
+
+  useEffect(() => () => downloadAbortRef.current?.abort(), []);
+
+  useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (event.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const togglePath = (path: string) => {
+    setSelectedPaths((current) => {
+      const next = new Set(current);
+      if (next.has(path)) next.delete(path);
+      else next.add(path);
+      return next;
+    });
+  };
+
+  const downloadLocalTimelapse = async () => {
+    const sourceName = mediaQuery.data?.local_timelapse?.name ?? '';
+    const extension = sourceName.includes('.') ? `.${sourceName.split('.').pop()}` : '';
+    try {
+      await api.downloadArchiveTimelapse(archiveId, `${archiveName}_timelapse${extension}`);
+    } catch (error) {
+      showToast(t('printerFiles.downloadFailed', {
+        error: error instanceof Error ? error.message : t('printerFiles.unknownError'),
+      }), 'error');
+    }
+  };
+
+  const downloadSelected = async () => {
+    if (!mediaQuery.data?.printer_id || selectedPaths.size === 0) return;
+    const selectedFiles = remoteFiles.filter(file => selectedPaths.has(file.path));
+    if (selectedFiles.length === 0) return;
+    const controller = new AbortController();
+    downloadAbortRef.current = controller;
+    setDownloadStarting(true);
+    try {
+      const result = await api.downloadPrinterFilesAsZip(
+        mediaQuery.data.printer_id,
+        selectedFiles.map(file => file.path),
+        Object.fromEntries(selectedFiles.map(file => [file.path, file.size])),
+        `${archiveName.replace(/[^a-zA-Z0-9]/g, '_')}-printer-videos.zip`,
+        true,
+        controller.signal,
+        (completed, total) => setDownloadProgress({ current: completed, total }),
+      );
+      if (result.failed > 0) {
+        showToast(t('printerFiles.zipPartial', {
+          successful: result.successful,
+          total: result.requested,
+        }), 'warning');
+      } else {
+        showToast(t('printerFiles.zipStarted', { count: result.successful }));
+      }
+    } catch (error) {
+      showToast(t('printerFiles.downloadFailed', {
+        error: error instanceof Error ? error.message : t('printerFiles.unknownError'),
+      }), 'error');
+    } finally {
+      if (downloadAbortRef.current === controller) downloadAbortRef.current = null;
+      setDownloadProgress(null);
+      setDownloadStarting(false);
+    }
+  };
+
+  const hasMedia = !!mediaQuery.data?.local_timelapse || remoteFiles.length > 0;
+
+  const warningText = (warning: string) => {
+    if (warning === 'printer_files_forbidden') return t('printers.permission.noFiles');
+    if (warning === 'printer_missing') return t('archives.media.printerMissing');
+    if (warning === 'timelapse_unavailable') return t('archives.media.timelapseUnavailable');
+    return t('archives.media.ipcamUnavailable');
+  };
+
+  const warnings = (mediaQuery.data?.warnings ?? []).map((warning) => (
+    <p key={warning} className="text-xs text-amber-600 dark:text-amber-400">
+      {warningText(warning)}
+    </p>
+  ));
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
+      <div
+        className="flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-bambu-dark-tertiary bg-bambu-dark-secondary"
+        onClick={(event) => event.stopPropagation()}
+      >
+        <div className="flex items-center justify-between border-b border-bambu-dark-tertiary p-4">
+          <div className="min-w-0">
+            <h3 className="flex items-center gap-2 text-lg font-semibold text-white">
+              <Video className="h-5 w-5 text-bambu-green" />
+              {t('archives.media.title')}
+            </h3>
+            <p className="truncate text-sm text-bambu-gray">{archiveName} · {printerName}</p>
+          </div>
+          <button onClick={onClose} className="rounded p-1 text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white">
+            <X className="h-5 w-5" />
+          </button>
+        </div>
+
+        <div className="flex-1 overflow-y-auto p-4">
+          {mediaQuery.isLoading ? (
+            <div className="flex items-center justify-center gap-2 py-12 text-bambu-gray">
+              <Loader2 className="h-5 w-5 animate-spin" />
+              {t('archives.media.searching')}
+            </div>
+          ) : mediaQuery.isError ? (
+            <p className="py-8 text-center text-red-500">
+              {t('archives.media.searchFailed')}
+            </p>
+          ) : !hasMedia ? (
+            <div className="space-y-3 py-8 text-center">
+              <p className="text-bambu-gray">{t('archives.media.none')}</p>
+              {warnings}
+            </div>
+          ) : (
+            <div className="space-y-4">
+              {mediaQuery.data?.local_timelapse && (
+                <div className="flex items-center gap-3 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3">
+                  <Film className="h-5 w-5 shrink-0 text-bambu-green" />
+                  <div className="min-w-0 flex-1">
+                    <p className="truncate text-sm font-medium text-white">
+                      {mediaQuery.data.local_timelapse.name}
+                    </p>
+                    <p className="text-xs text-bambu-gray">
+                      {t('archives.media.attachedTimelapse')} · {formatFileSize(mediaQuery.data.local_timelapse.size)}
+                    </p>
+                  </div>
+                  <Button variant="secondary" size="sm" onClick={downloadLocalTimelapse}>
+                    <Download className="h-4 w-4" />
+                    {t('common.download')}
+                  </Button>
+                </div>
+              )}
+
+              {remoteFiles.length > 0 && (
+                <div>
+                  <div className="mb-2 flex items-center justify-between gap-2">
+                    <p className="text-sm text-bambu-gray">
+                      {t('archives.media.printerFiles')} ({remoteFiles.length})
+                    </p>
+                    <button
+                      className="text-xs text-bambu-green hover:text-bambu-green-light"
+                      onClick={() => setSelectedPaths(
+                        selectedPaths.size === remoteFiles.length
+                          ? new Set()
+                          : new Set(remoteFiles.map((file) => file.path)),
+                      )}
+                    >
+                      {selectedPaths.size === remoteFiles.length
+                        ? t('common.deselectAll')
+                        : t('common.selectAll')}
+                    </button>
+                  </div>
+                  <div className="space-y-1">
+                    {remoteFiles.map((file) => {
+                      const selected = selectedPaths.has(file.path);
+                      return (
+                        <button
+                          key={file.path}
+                          className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
+                            selected
+                              ? 'border-bambu-green/60 bg-bambu-green/10'
+                              : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
+                          }`}
+                          onClick={() => togglePath(file.path)}
+                        >
+                          {selected
+                            ? <CheckSquare className="h-5 w-5 shrink-0 text-bambu-green" />
+                            : <Square className="h-5 w-5 shrink-0 text-bambu-gray" />}
+                          {file.kind === 'timelapse'
+                            ? <Film className="h-5 w-5 shrink-0 text-bambu-green" />
+                            : <Video className="h-5 w-5 shrink-0 text-blue-400" />}
+                          <span className="min-w-0 flex-1 truncate text-sm text-white">{file.name}</span>
+                          <span className="shrink-0 text-xs text-bambu-gray">{formatFileSize(file.size)}</span>
+                        </button>
+                      );
+                    })}
+                  </div>
+                </div>
+              )}
+
+              {warnings}
+            </div>
+          )}
+        </div>
+
+        {remoteFiles.length > 0 && (
+          <div className="flex items-center justify-end border-t border-bambu-dark-tertiary p-4">
+            <Button
+              variant="primary"
+              onClick={downloadSelected}
+              disabled={selectedPaths.size === 0 || downloadStarting}
+            >
+              {downloadStarting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
+              {downloadProgress
+                ? `${downloadProgress.current}/${downloadProgress.total}`
+                : `${t('archives.media.downloadSelected')} (${selectedPaths.size})`}
+            </Button>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 122 - 66
frontend/src/components/FileManagerModal.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import {
@@ -290,6 +290,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
   const [sortBy, setSortBy] = useState<SortOption>('name-asc');
   const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);
   const [viewerFile, setViewerFile] = useState<{ path: string; name: string } | null>(null);
+  const selectionAnchorRef = useRef<string | null>(null);
+  const downloadAbortRef = useRef<AbortController | null>(null);
 
   // Close on Escape key
   useEffect(() => {
@@ -317,6 +319,52 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
     staleTime: 30000, // Cache for 30 seconds
   });
 
+  const visibleFiles = useMemo(() => [...(data?.files ?? [])]
+    .filter((file) => !searchQuery || file.name.toLowerCase().includes(searchQuery.toLowerCase()))
+    .sort((a, b) => {
+      if (a.is_directory && !b.is_directory) return -1;
+      if (!a.is_directory && b.is_directory) return 1;
+
+      switch (sortBy) {
+        case 'name-asc':
+          return a.name.localeCompare(b.name);
+        case 'name-desc':
+          return b.name.localeCompare(a.name);
+        case 'size-asc':
+          return a.size - b.size;
+        case 'size-desc':
+          return b.size - a.size;
+        case 'date-asc': {
+          const aTime = a.mtime ? parseUTCDate(a.mtime)?.getTime() ?? 0 : 0;
+          const bTime = b.mtime ? parseUTCDate(b.mtime)?.getTime() ?? 0 : 0;
+          return aTime - bTime;
+        }
+        case 'date-desc': {
+          const aTime = a.mtime ? parseUTCDate(a.mtime)?.getTime() ?? 0 : 0;
+          const bTime = b.mtime ? parseUTCDate(b.mtime)?.getTime() ?? 0 : 0;
+          return bTime - aTime;
+        }
+        default:
+          return a.name.localeCompare(b.name);
+      }
+    }), [data?.files, searchQuery, sortBy]);
+
+  // Drop selections the user can no longer see -- but only when the listing is
+  // real. An unreachable printer answers with an empty file list and a warning,
+  // and treating that as "those files are gone" would throw away a selection
+  // the user made moments ago because one poll happened to fail.
+  const listingIsReal = !!data && !data.warnings?.includes('printer_unavailable');
+  useEffect(() => {
+    if (!listingIsReal) return;
+    const visiblePaths = new Set(visibleFiles.filter(file => !file.is_directory).map(file => file.path));
+    setSelectedFiles(current => new Set([...current].filter(path => visiblePaths.has(path))));
+    if (selectionAnchorRef.current && !visiblePaths.has(selectionAnchorRef.current)) {
+      selectionAnchorRef.current = null;
+    }
+  }, [visibleFiles, listingIsReal]);
+
+  useEffect(() => () => downloadAbortRef.current?.abort(), []);
+
   const deleteMutation = useMutation({
     mutationFn: async (paths: string[]) => {
       // Delete files one by one
@@ -328,6 +376,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
       showToast(t('printerFiles.toast.filesDeleted', { count: filesToDelete.length }));
       queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });
       setSelectedFiles(new Set());
+      selectionAnchorRef.current = null;
       setFilesToDelete([]);
     },
     onError: (error: Error) => {
@@ -338,6 +387,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
   const navigateToFolder = (path: string) => {
     setCurrentPath(path);
     setSelectedFiles(new Set());
+    selectionAnchorRef.current = null;
   };
 
   const navigateUp = () => {
@@ -346,13 +396,30 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
     parts.pop();
     setCurrentPath(parts.length ? '/' + parts.join('/') : '/');
     setSelectedFiles(new Set());
+    selectionAnchorRef.current = null;
   };
 
   const toggleFileSelection = (path: string, e: React.MouseEvent) => {
     e.stopPropagation();
+    const selectablePaths = visibleFiles.filter(file => !file.is_directory).map(file => file.path);
+    const anchorIndex = selectionAnchorRef.current
+      ? selectablePaths.indexOf(selectionAnchorRef.current)
+      : -1;
+    const targetIndex = selectablePaths.indexOf(path);
+    const extendsRange = e.shiftKey && anchorIndex !== -1 && targetIndex !== -1;
+
+    // Moved out of the state updater deliberately: React may run an updater
+    // more than once, and a ref assignment is not the kind of thing that
+    // survives being replayed by accident.
+    if (!extendsRange) selectionAnchorRef.current = path;
+
     setSelectedFiles(prev => {
       const next = new Set(prev);
-      if (next.has(path)) {
+      if (extendsRange) {
+        const start = Math.min(anchorIndex, targetIndex);
+        const end = Math.max(anchorIndex, targetIndex);
+        selectablePaths.slice(start, end + 1).forEach(rangePath => next.add(rangePath));
+      } else if (next.has(path)) {
         next.delete(path);
       } else {
         next.add(path);
@@ -362,55 +429,66 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
   };
 
   const selectAllFiles = () => {
-    if (!data?.files) return;
-    const filePaths = data.files
-      .filter(f => !f.is_directory && (!searchQuery || f.name.toLowerCase().includes(searchQuery.toLowerCase())))
-      .map(f => f.path);
+    const filePaths = visibleFiles.filter(file => !file.is_directory).map(file => file.path);
     setSelectedFiles(new Set(filePaths));
+    selectionAnchorRef.current = null;
   };
 
   const deselectAllFiles = () => {
     setSelectedFiles(new Set());
+    selectionAnchorRef.current = null;
   };
 
   const handleDownload = async () => {
     if (selectedFiles.size === 0) return;
 
-    const paths = Array.from(selectedFiles);
+    const paths = visibleFiles.filter(file => !file.is_directory && selectedFiles.has(file.path)).map(file => file.path);
+    if (paths.length === 0) return;
+    const controller = new AbortController();
+    downloadAbortRef.current = controller;
 
-    if (paths.length === 1) {
-      // Single file - direct download with auth
-      api.downloadPrinterFile(printerId, paths[0]).catch((err) => {
-        console.error('Printer file download failed:', err);
-      });
-      setSelectedFiles(new Set());
-      return;
-    }
-
-    // Multiple files - download as ZIP
     setDownloadProgress({ current: 0, total: paths.length });
     try {
-      const blob = await api.downloadPrinterFilesAsZip(printerId, paths);
-      const url = URL.createObjectURL(blob);
-      const a = document.createElement('a');
-      a.href = url;
-      a.download = `${printerName.replace(/[^a-zA-Z0-9]/g, '_')}-files.zip`;
-      document.body.appendChild(a);
-      a.click();
-      document.body.removeChild(a);
-      URL.revokeObjectURL(url);
-      showToast(`Downloaded ${paths.length} files as ZIP`);
+      const sizes = Object.fromEntries(paths.map(path => [
+        path,
+        data?.files.find(file => file.path === path)?.size ?? 0,
+      ]));
+      const result = await api.downloadPrinterFilesAsZip(
+        printerId,
+        paths,
+        sizes,
+        paths.length === 1
+          ? data?.files.find(file => file.path === paths[0])?.name ?? 'printer-file'
+          : `${printerName.replace(/[^a-zA-Z0-9]/g, '_')}-files.zip`,
+        paths.length > 1,
+        controller.signal,
+        (completed, total) => setDownloadProgress({ current: completed, total }),
+      );
+      if (result.failed > 0) {
+        showToast(t('printerFiles.zipPartial', {
+          successful: result.successful,
+          total: result.requested,
+        }), 'warning');
+      } else {
+        showToast(t('printerFiles.zipStarted', { count: result.successful }));
+      }
       setSelectedFiles(new Set());
+      selectionAnchorRef.current = null;
     } catch (error) {
-      showToast(`Download failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
+      showToast(t('printerFiles.downloadFailed', {
+        error: error instanceof Error ? error.message : t('printerFiles.unknownError'),
+      }), 'error');
     } finally {
+      if (downloadAbortRef.current === controller) downloadAbortRef.current = null;
       setDownloadProgress(null);
     }
   };
 
   const handleDelete = () => {
     if (selectedFiles.size === 0) return;
-    setFilesToDelete(Array.from(selectedFiles));
+    setFilesToDelete(
+      visibleFiles.filter(file => !file.is_directory && selectedFiles.has(file.path)).map(file => file.path),
+    );
   };
 
   // Quick navigation buttons for common directories
@@ -515,6 +593,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
             size="sm"
             onClick={() => refetch()}
             disabled={isLoading}
+            aria-label={t('common.refresh')}
+            title={t('common.refresh')}
           >
             <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
           </Button>
@@ -540,47 +620,17 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               <div className="flex items-center justify-center py-12">
                 <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
               </div>
+            ) : data?.warnings?.includes('printer_unavailable') ? (
+              <div className="text-center py-12 text-amber-600 dark:text-amber-400">
+                {t('printerFiles.printerUnavailable')}
+              </div>
             ) : !data?.files?.length ? (
               <div className="text-center py-12 text-bambu-gray">
-                No files in this directory
+                {t('printerFiles.noFiles')}
               </div>
             ) : (
               <div className="space-y-1">
-                {/* Filter and sort: directories first, then files with selected sort */}
-                {[...data.files]
-                  .filter((file) =>
-                    !searchQuery || file.name.toLowerCase().includes(searchQuery.toLowerCase())
-                  )
-                  .sort((a, b) => {
-                    // Directories always first
-                    if (a.is_directory && !b.is_directory) return -1;
-                    if (!a.is_directory && b.is_directory) return 1;
-
-                    // Apply selected sort within same type
-                    switch (sortBy) {
-                      case 'name-asc':
-                        return a.name.localeCompare(b.name);
-                      case 'name-desc':
-                        return b.name.localeCompare(a.name);
-                      case 'size-asc':
-                        return a.size - b.size;
-                      case 'size-desc':
-                        return b.size - a.size;
-                      case 'date-asc': {
-                        const aTime = a.mtime ? parseUTCDate(a.mtime)?.getTime() ?? 0 : 0;
-                        const bTime = b.mtime ? parseUTCDate(b.mtime)?.getTime() ?? 0 : 0;
-                        return aTime - bTime;
-                      }
-                      case 'date-desc': {
-                        const aTime = a.mtime ? parseUTCDate(a.mtime)?.getTime() ?? 0 : 0;
-                        const bTime = b.mtime ? parseUTCDate(b.mtime)?.getTime() ?? 0 : 0;
-                        return bTime - aTime;
-                      }
-                      default:
-                        return a.name.localeCompare(b.name);
-                    }
-                  })
-                  .map((file) => {
+                {visibleFiles.map((file) => {
                     const FileIcon = getFileIcon(file.name, file.is_directory);
                     const isSelected = selectedFiles.has(file.path);
 
@@ -592,9 +642,11 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
                             ? 'bg-bambu-green/20 border border-bambu-green/50'
                             : 'hover:bg-bambu-dark-tertiary'
                         }`}
-                        onClick={() => {
+                        onClick={(event) => {
                           if (file.is_directory) {
                             navigateToFolder(file.path);
+                          } else {
+                            toggleFileSelection(file.path, event);
                           }
                         }}
                       >
@@ -603,6 +655,10 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
                           <button
                             onClick={(e) => toggleFileSelection(file.path, e)}
                             className="flex-shrink-0 text-bambu-gray hover:text-white"
+                            aria-label={t(isSelected ? 'printerFiles.deselectFile' : 'printerFiles.selectFile', {
+                              name: file.name,
+                            })}
+                            title={t('printerFiles.shiftSelectHint')}
                           >
                             {isSelected ? (
                               <CheckSquare className="w-5 h-5 text-bambu-green" />

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

@@ -870,6 +870,19 @@ export default {
 
   // Archives page
   archives: {
+    media: {
+      title: 'Druckvideos',
+      download: 'Druckvideos herunterladen',
+      searching: 'Der Drucker wird nach Videos durchsucht…',
+      searchFailed: 'Der Drucker konnte nicht nach Videos durchsucht werden.',
+      none: 'Für diesen Druck wurden keine Zeitraffer- oder IP-Kamera-Clips gefunden.',
+      attachedTimelapse: 'Angehängter Zeitraffer',
+      printerFiles: 'Noch auf dem Drucker vorhandene Dateien',
+      downloadSelected: 'Auswahl herunterladen',
+      printerMissing: 'Der Drucker ist nicht mehr konfiguriert',
+      timelapseUnavailable: 'Das Zeitrafferverzeichnis konnte nicht gelesen werden',
+      ipcamUnavailable: 'Das IP-Kamera-Verzeichnis konnte nicht gelesen werden',
+    },
     title: 'Druckarchiv',
     no3mfBanner: {
       title: 'Einige kürzliche Drucke konnten nicht mit Vorschaubild archiviert werden',
@@ -3105,6 +3118,14 @@ export default {
 
   // Printer File Manager modal (printer internal storage)
   printerFiles: {
+    zipStarted_one: 'ZIP-Download für {{count}} Datei gestartet',
+    zipStarted_other: 'ZIP-Download für {{count}} Dateien gestartet',
+    zipPartial: 'ZIP-Download mit {{successful}} von {{total}} Dateien gestartet; die übrigen konnten nicht abgerufen werden',
+    downloadFailed: 'Download fehlgeschlagen: {{error}}',
+    unknownError: 'Unbekannter Fehler',
+    shiftSelectHint: 'Umschalttaste gedrückt halten, um einen Bereich auszuwählen',
+    selectFile: '{{name}} auswählen',
+    deselectFile: 'Auswahl von {{name}} aufheben',
     title: 'Dateimanager',
     storageUsed: 'Belegt:',
     storageFree: 'Frei:',
@@ -3114,6 +3135,7 @@ export default {
     deleteFileConfirm: '"{{name}}" löschen? Dies kann nicht rückgängig gemacht werden.',
     deleteFilesConfirm: '{{count}} ausgewählte Dateien löschen? Dies kann nicht rückgängig gemacht werden.',
     noFiles: 'Keine Dateien auf dem Drucker',
+    printerUnavailable: 'Der Dateidienst des Druckers ist nicht erreichbar. Versuchen Sie es erneut, wenn der Drucker verfügbar ist.',
     loadingFiles: 'Dateien werden geladen...',
     failedToLoad: 'Dateien konnten nicht geladen werden',
     toast: {

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

@@ -875,6 +875,19 @@ export default {
 
   // Archives page
   archives: {
+    media: {
+      title: 'Print videos',
+      download: 'Download print videos',
+      searching: 'Checking the printer for videos…',
+      searchFailed: 'Could not check the printer for videos.',
+      none: 'No timelapse or IP camera chunks were found for this print.',
+      attachedTimelapse: 'Attached timelapse',
+      printerFiles: 'Files still on the printer',
+      downloadSelected: 'Download selected',
+      printerMissing: 'Printer is no longer configured',
+      timelapseUnavailable: 'The timelapse directory could not be read',
+      ipcamUnavailable: 'The IP camera directory could not be read',
+    },
     title: 'Print Archives',
     no3mfBanner: {
       title: 'Some recent prints couldn\'t be archived with thumbnails',
@@ -3135,6 +3148,14 @@ export default {
 
   // Printer File Manager modal (printer internal storage)
   printerFiles: {
+    zipStarted_one: 'Started ZIP download for {{count}} file',
+    zipStarted_other: 'Started ZIP download for {{count}} files',
+    zipPartial: 'ZIP download started with {{successful}} of {{total}} files; the rest could not be retrieved',
+    downloadFailed: 'Download failed: {{error}}',
+    unknownError: 'Unknown error',
+    shiftSelectHint: 'Shift-click to select a range',
+    selectFile: 'Select {{name}}',
+    deselectFile: 'Deselect {{name}}',
     title: 'File Manager',
     storageUsed: 'Used:',
     storageFree: 'Free:',
@@ -3144,6 +3165,7 @@ export default {
     deleteFileConfirm: 'Delete "{{name}}"? This cannot be undone.',
     deleteFilesConfirm: 'Delete {{count}} selected files? This cannot be undone.',
     noFiles: 'No files on printer',
+    printerUnavailable: 'The printer file service is unavailable. Try again when the printer is reachable.',
     loadingFiles: 'Loading files...',
     failedToLoad: 'Failed to load files',
     toast: {

+ 22 - 0
frontend/src/i18n/locales/es.ts

@@ -870,6 +870,19 @@ export default {
 
   // Archives page
   archives: {
+    media: {
+      title: 'Vídeos de la impresión',
+      download: 'Descargar vídeos de la impresión',
+      searching: 'Buscando vídeos en la impresora…',
+      searchFailed: 'No se pudieron buscar vídeos en la impresora.',
+      none: 'No se encontraron vídeos timelapse ni fragmentos de cámara IP para esta impresión.',
+      attachedTimelapse: 'Timelapse adjunto',
+      printerFiles: 'Archivos que aún están en la impresora',
+      downloadSelected: 'Descargar seleccionados',
+      printerMissing: 'La impresora ya no está configurada',
+      timelapseUnavailable: 'No se pudo leer el directorio de timelapses',
+      ipcamUnavailable: 'No se pudo leer el directorio de la cámara IP',
+    },
     title: 'Archivos de impresión',
     no3mfBanner: {
       title: 'Algunas impresiones recientes no se pudieron archivar con miniaturas',
@@ -3107,6 +3120,14 @@ export default {
 
   // Printer File Manager modal (printer internal storage)
   printerFiles: {
+    zipStarted_one: 'Se inició la descarga ZIP de {{count}} archivo',
+    zipStarted_other: 'Se inició la descarga ZIP de {{count}} archivos',
+    zipPartial: 'La descarga ZIP comenzó con {{successful}} de {{total}} archivos; no se pudieron recuperar los demás',
+    downloadFailed: 'Error de descarga: {{error}}',
+    unknownError: 'Error desconocido',
+    shiftSelectHint: 'Mayús-clic para seleccionar un intervalo',
+    selectFile: 'Seleccionar {{name}}',
+    deselectFile: 'Deseleccionar {{name}}',
     title: 'Gestor de archivos',
     storageUsed: 'Usado:',
     storageFree: 'Libre:',
@@ -3116,6 +3137,7 @@ export default {
     deleteFileConfirm: '¿Eliminar "{{name}}"? Esto no se puede deshacer.',
     deleteFilesConfirm: '¿Eliminar {{count}} archivos seleccionados? Esto no se puede deshacer.',
     noFiles: 'No hay archivos en la impresora',
+    printerUnavailable: 'El servicio de archivos de la impresora no está disponible. Inténtelo de nuevo cuando la impresora esté accesible.',
     loadingFiles: 'Cargando archivos...',
     failedToLoad: 'Error al cargar los archivos',
     toast: {

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

@@ -870,6 +870,19 @@ export default {
 
   // Archives page
   archives: {
+    media: {
+      title: 'Vidéos d’impression',
+      download: 'Télécharger les vidéos d’impression',
+      searching: 'Recherche de vidéos sur l’imprimante…',
+      searchFailed: 'Impossible de rechercher les vidéos sur l’imprimante.',
+      none: 'Aucun timelapse ni segment de caméra IP n’a été trouvé pour cette impression.',
+      attachedTimelapse: 'Timelapse joint',
+      printerFiles: 'Fichiers encore présents sur l’imprimante',
+      downloadSelected: 'Télécharger la sélection',
+      printerMissing: 'L’imprimante n’est plus configurée',
+      timelapseUnavailable: 'Impossible de lire le dossier des timelapses',
+      ipcamUnavailable: 'Impossible de lire le dossier de la caméra IP',
+    },
     title: 'Archives d\'impression',
     no3mfBanner: {
       title: 'Certaines impressions récentes n\'ont pas pu être archivées avec leur miniature',
@@ -3094,6 +3107,14 @@ export default {
 
   // Printer File Manager modal (printer internal storage)
   printerFiles: {
+    zipStarted_one: 'Téléchargement ZIP de {{count}} fichier démarré',
+    zipStarted_other: 'Téléchargement ZIP de {{count}} fichiers démarré',
+    zipPartial: 'Le téléchargement ZIP a démarré avec {{successful}} fichiers sur {{total}} ; les autres n’ont pas pu être récupérés',
+    downloadFailed: 'Échec du téléchargement : {{error}}',
+    unknownError: 'Erreur inconnue',
+    shiftSelectHint: 'Maj-clic pour sélectionner une plage',
+    selectFile: 'Sélectionner {{name}}',
+    deselectFile: 'Désélectionner {{name}}',
     title: 'Gestionnaire de fichiers',
     storageUsed: 'Utilisé :',
     storageFree: 'Libre :',
@@ -3103,6 +3124,7 @@ export default {
     deleteFileConfirm: 'Supprimer "{{name}}" ?',
     deleteFilesConfirm: 'Supprimer les {{count}} fichiers sélectionnés ?',
     noFiles: 'Aucun fichier sur l\'imprimante',
+    printerUnavailable: 'Le service de fichiers de l\'imprimante est indisponible. Réessayez lorsque l\'imprimante est accessible.',
     loadingFiles: 'Chargement...',
     failedToLoad: 'Échec chargement fichiers',
     toast: {

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

@@ -870,6 +870,19 @@ export default {
 
   // Archives page
   archives: {
+    media: {
+      title: 'Video della stampa',
+      download: 'Scarica i video della stampa',
+      searching: 'Ricerca dei video sulla stampante…',
+      searchFailed: 'Impossibile cercare i video sulla stampante.',
+      none: 'Non sono stati trovati timelapse o segmenti della videocamera IP per questa stampa.',
+      attachedTimelapse: 'Timelapse allegato',
+      printerFiles: 'File ancora presenti sulla stampante',
+      downloadSelected: 'Scarica selezionati',
+      printerMissing: 'La stampante non è più configurata',
+      timelapseUnavailable: 'Impossibile leggere la cartella dei timelapse',
+      ipcamUnavailable: 'Impossibile leggere la cartella della videocamera IP',
+    },
     title: 'Archivi di stampa',
     no3mfBanner: {
       title: 'Alcune stampe recenti non sono state archiviate con la miniatura',
@@ -3093,6 +3106,14 @@ export default {
 
   // Printer File Manager modal (printer internal storage)
   printerFiles: {
+    zipStarted_one: 'Download ZIP avviato per {{count}} file',
+    zipStarted_other: 'Download ZIP avviato per {{count}} file',
+    zipPartial: 'Download ZIP avviato con {{successful}} file su {{total}}; non è stato possibile recuperare gli altri',
+    downloadFailed: 'Download non riuscito: {{error}}',
+    unknownError: 'Errore sconosciuto',
+    shiftSelectHint: 'Maiusc-clic per selezionare un intervallo',
+    selectFile: 'Seleziona {{name}}',
+    deselectFile: 'Deseleziona {{name}}',
     title: 'Gestore file',
     storageUsed: 'Usato:',
     storageFree: 'Libero:',
@@ -3102,6 +3123,7 @@ export default {
     deleteFileConfirm: 'Eliminare "{{name}}"? Questa azione non può essere annullata.',
     deleteFilesConfirm: 'Eliminare {{count}} file selezionati? Questa azione non può essere annullata.',
     noFiles: 'Nessun file sulla stampante',
+    printerUnavailable: 'Il servizio file della stampante non è disponibile. Riprova quando la stampante è raggiungibile.',
     loadingFiles: 'Caricamento file...',
     failedToLoad: 'Caricamento file fallito',
     toast: {

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

@@ -869,6 +869,19 @@ export default {
 
   // Archives page
   archives: {
+    media: {
+      title: '印刷動画',
+      download: '印刷動画をダウンロード',
+      searching: 'プリンター上の動画を確認しています…',
+      searchFailed: 'プリンター上の動画を確認できませんでした。',
+      none: 'この印刷のタイムラプスまたは IP カメラの動画断片は見つかりませんでした。',
+      attachedTimelapse: '添付済みタイムラプス',
+      printerFiles: 'プリンターに残っているファイル',
+      downloadSelected: '選択項目をダウンロード',
+      printerMissing: 'プリンターは設定から削除されています',
+      timelapseUnavailable: 'タイムラプスディレクトリを読み取れませんでした',
+      ipcamUnavailable: 'IP カメラディレクトリを読み取れませんでした',
+    },
     title: '印刷アーカイブ',
     no3mfBanner: {
       title: '最近の一部の印刷でサムネイル付きのアーカイブができませんでした',
@@ -3105,6 +3118,14 @@ export default {
 
   // Printer File Manager modal (printer internal storage)
   printerFiles: {
+    zipStarted_one: '{{count}} 個のファイルの ZIP ダウンロードを開始しました',
+    zipStarted_other: '{{count}} 個のファイルの ZIP ダウンロードを開始しました',
+    zipPartial: '{{total}} 個中 {{successful}} 個のファイルで ZIP ダウンロードを開始しました。残りは取得できませんでした',
+    downloadFailed: 'ダウンロードに失敗しました: {{error}}',
+    unknownError: '不明なエラー',
+    shiftSelectHint: 'Shift キーを押しながらクリックして範囲を選択',
+    selectFile: '{{name}} を選択',
+    deselectFile: '{{name}} の選択を解除',
     title: 'ファイル管理',
     storageUsed: '使用中:',
     storageFree: '空き:',
@@ -3114,6 +3135,7 @@ export default {
     deleteFileConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。',
     deleteFilesConfirm: '選択した{{count}}件のファイルを削除しますか?元に戻せません。',
     noFiles: 'このディレクトリにファイルがありません',
+    printerUnavailable: 'プリンターのファイルサービスを利用できません。プリンターに接続できる状態で再試行してください。',
     loadingFiles: 'ファイルを読み込み中...',
     failedToLoad: 'ファイルの読み込みに失敗しました',
     toast: {

+ 22 - 0
frontend/src/i18n/locales/ko.ts

@@ -826,6 +826,19 @@ export default {
     }
   },
   archives: {
+    media: {
+      title: '출력 동영상',
+      download: '출력 동영상 다운로드',
+      searching: '프린터에서 동영상을 확인하는 중…',
+      searchFailed: '프린터에서 동영상을 확인할 수 없습니다.',
+      none: '이 출력의 타임랩스 또는 IP 카메라 영상 조각을 찾지 못했습니다.',
+      attachedTimelapse: '첨부된 타임랩스',
+      printerFiles: '프린터에 남아 있는 파일',
+      downloadSelected: '선택 항목 다운로드',
+      printerMissing: '프린터가 더 이상 구성되어 있지 않습니다',
+      timelapseUnavailable: '타임랩스 디렉터리를 읽을 수 없습니다',
+      ipcamUnavailable: 'IP 카메라 디렉터리를 읽을 수 없습니다',
+    },
     title: '인쇄 아카이브',
     no3mfBanner: {
       title: '최근 일부 인쇄가 썸네일과 함께 아카이브되지 않았습니다',
@@ -2949,6 +2962,14 @@ export default {
     all: '전체'
   },
   printerFiles: {
+    zipStarted_one: '{{count}}개 파일의 ZIP 다운로드를 시작했습니다',
+    zipStarted_other: '{{count}}개 파일의 ZIP 다운로드를 시작했습니다',
+    zipPartial: '{{total}}개 중 {{successful}}개 파일로 ZIP 다운로드를 시작했습니다. 나머지는 가져오지 못했습니다',
+    downloadFailed: '다운로드 실패: {{error}}',
+    unknownError: '알 수 없는 오류',
+    shiftSelectHint: 'Shift 키를 누른 채 클릭하여 범위 선택',
+    selectFile: '{{name}} 선택',
+    deselectFile: '{{name}} 선택 해제',
     title: '파일 관리자',
     storageUsed: '사용됨:',
     storageFree: '여유:',
@@ -2958,6 +2979,7 @@ export default {
     deleteFileConfirm: '"{{name}}"을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
     deleteFilesConfirm: '선택된 {{count}}개 파일을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
     noFiles: '프린터에 파일 없음',
+    printerUnavailable: '프린터 파일 서비스를 사용할 수 없습니다. 프린터에 연결할 수 있을 때 다시 시도하세요.',
     loadingFiles: '파일 로딩 중...',
     failedToLoad: '파일 로드 실패',
     toast: {

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

@@ -870,6 +870,19 @@ export default {
 
   // Archives page
   archives: {
+    media: {
+      title: 'Vídeos da impressão',
+      download: 'Baixar vídeos da impressão',
+      searching: 'Procurando vídeos na impressora…',
+      searchFailed: 'Não foi possível procurar vídeos na impressora.',
+      none: 'Nenhum timelapse ou trecho da câmera IP foi encontrado para esta impressão.',
+      attachedTimelapse: 'Timelapse anexado',
+      printerFiles: 'Arquivos ainda presentes na impressora',
+      downloadSelected: 'Baixar selecionados',
+      printerMissing: 'A impressora não está mais configurada',
+      timelapseUnavailable: 'Não foi possível ler o diretório de timelapses',
+      ipcamUnavailable: 'Não foi possível ler o diretório da câmera IP',
+    },
     title: 'Arquivos de Impressão',
     no3mfBanner: {
       title: 'Algumas impressões recentes não puderam ser arquivadas com miniaturas',
@@ -3093,6 +3106,14 @@ export default {
 
   // Printer File Manager modal (printer internal storage)
   printerFiles: {
+    zipStarted_one: 'Download ZIP iniciado para {{count}} arquivo',
+    zipStarted_other: 'Download ZIP iniciado para {{count}} arquivos',
+    zipPartial: 'Download ZIP iniciado com {{successful}} de {{total}} arquivos; não foi possível obter os demais',
+    downloadFailed: 'Falha no download: {{error}}',
+    unknownError: 'Erro desconhecido',
+    shiftSelectHint: 'Shift+clique para selecionar um intervalo',
+    selectFile: 'Selecionar {{name}}',
+    deselectFile: 'Desmarcar {{name}}',
     title: 'Gerenciador de Arquivos',
     storageUsed: 'Usado:',
     storageFree: 'Livre:',
@@ -3102,6 +3123,7 @@ export default {
     deleteFileConfirm: 'Excluir "{{name}}"? Isso não pode ser desfeito.',
     deleteFilesConfirm: 'Excluir {{count}} arquivos selecionados? Isso não pode ser desfeito.',
     noFiles: 'Nenhum arquivo na impressora',
+    printerUnavailable: 'O serviço de arquivos da impressora está indisponível. Tente novamente quando a impressora estiver acessível.',
     loadingFiles: 'Carregando arquivos...',
     failedToLoad: 'Falha ao carregar arquivos',
     toast: {

+ 22 - 0
frontend/src/i18n/locales/ru.ts

@@ -825,6 +825,19 @@ export default {
     dropToQueue: "Перетащите файл в очередь",
   },
   archives: {
+    media: {
+      title: 'Видео печати',
+      download: 'Скачать видео печати',
+      searching: 'Поиск видео на принтере…',
+      searchFailed: 'Не удалось проверить видео на принтере.',
+      none: 'Для этой печати не найдены таймлапсы или фрагменты IP-камеры.',
+      attachedTimelapse: 'Прикреплённый таймлапс',
+      printerFiles: 'Файлы, оставшиеся на принтере',
+      downloadSelected: 'Скачать выбранное',
+      printerMissing: 'Принтер больше не настроен',
+      timelapseUnavailable: 'Не удалось прочитать каталог таймлапсов',
+      ipcamUnavailable: 'Не удалось прочитать каталог IP-камеры',
+    },
     title: "Архив печати",
     no3mfBanner: {
       title: "Некоторые последние задания сохранены без миниатюр",
@@ -2941,6 +2954,14 @@ export default {
     all: "Все",
   },
   printerFiles: {
+    zipStarted_one: 'Начато скачивание ZIP для {{count}} файла',
+    zipStarted_other: 'Начато скачивание ZIP для {{count}} файлов',
+    zipPartial: 'Начато скачивание ZIP с {{successful}} из {{total}} файлов; остальные получить не удалось',
+    downloadFailed: 'Ошибка скачивания: {{error}}',
+    unknownError: 'Неизвестная ошибка',
+    shiftSelectHint: 'Щёлкните с Shift, чтобы выбрать диапазон',
+    selectFile: 'Выбрать {{name}}',
+    deselectFile: 'Снять выбор с {{name}}',
     title: "Файлы принтера",
     storageUsed: "Занято:",
     storageFree: "Свободно:",
@@ -2950,6 +2971,7 @@ export default {
     deleteFileConfirm: "Удалить «{{name}}»? Это действие нельзя отменить.",
     deleteFilesConfirm: "Удалить выбранные файлы ({{count}})? Это действие нельзя отменить.",
     noFiles: "На принтере нет файлов",
+    printerUnavailable: 'Файловая служба принтера недоступна. Повторите попытку, когда принтер будет доступен.',
     loadingFiles: "Загрузка файлов...",
     failedToLoad: "Не удалось загрузить файлы",
     toast: {

+ 22 - 0
frontend/src/i18n/locales/tr.ts

@@ -870,6 +870,19 @@ export default {
 
   // Arşivler sayfası
   archives: {
+    media: {
+      title: 'Baskı videoları',
+      download: 'Baskı videolarını indir',
+      searching: 'Yazıcıda video aranıyor…',
+      searchFailed: 'Yazıcıdaki videolar kontrol edilemedi.',
+      none: 'Bu baskı için hızlandırılmış çekim veya IP kamera parçası bulunamadı.',
+      attachedTimelapse: 'Ekli hızlandırılmış çekim',
+      printerFiles: 'Yazıcıda kalan dosyalar',
+      downloadSelected: 'Seçilenleri indir',
+      printerMissing: 'Yazıcı artık yapılandırılmış değil',
+      timelapseUnavailable: 'Hızlandırılmış çekim dizini okunamadı',
+      ipcamUnavailable: 'IP kamera dizini okunamadı',
+    },
     title: 'Baskı Arşivleri',
     no3mfBanner: {
       title: 'Bazı son baskılar küçük resimlerle birlikte arşivlenemedi',
@@ -3108,6 +3121,14 @@ export default {
 
   // Yazıcı Dosya Yöneticisi modali (yazıcı dahili deposu)
   printerFiles: {
+    zipStarted_one: '{{count}} dosya için ZIP indirmesi başlatıldı',
+    zipStarted_other: '{{count}} dosya için ZIP indirmesi başlatıldı',
+    zipPartial: '{{total}} dosyanın {{successful}} tanesiyle ZIP indirmesi başlatıldı; kalan dosyalar alınamadı',
+    downloadFailed: 'İndirme başarısız: {{error}}',
+    unknownError: 'Bilinmeyen hata',
+    shiftSelectHint: 'Bir aralık seçmek için Shift tuşuyla tıklayın',
+    selectFile: '{{name}} öğesini seç',
+    deselectFile: '{{name}} seçimini kaldır',
     title: 'Dosya Yöneticisi',
     storageUsed: 'Kullanılan:',
     storageFree: 'Boş:',
@@ -3117,6 +3138,7 @@ export default {
     deleteFileConfirm: '"{{name}}" silinsin mi? Bu geri alınamaz.',
     deleteFilesConfirm: 'Seçilen {{count}} dosya silinsin mi? Bu geri alınamaz.',
     noFiles: 'Yazıcıda dosya yok',
+    printerUnavailable: 'Yazıcı dosya hizmeti kullanılamıyor. Yazıcıya erişilebildiğinde tekrar deneyin.',
     loadingFiles: 'Dosyalar yükleniyor...',
     failedToLoad: 'Dosyalar yüklenemedi',
     toast: {

+ 22 - 0
frontend/src/i18n/locales/uk.ts

@@ -874,6 +874,19 @@ export default {
 
   // Archives page
   archives: {
+    media: {
+      title: 'Відео друку',
+      download: 'Завантажити відео друку',
+      searching: 'Пошук відео на принтері…',
+      searchFailed: 'Не вдалося перевірити відео на принтері.',
+      none: 'Для цього друку не знайдено таймлапсів або фрагментів IP-камери.',
+      attachedTimelapse: 'Прикріплений таймлапс',
+      printerFiles: 'Файли, що залишилися на принтері',
+      downloadSelected: 'Завантажити вибране',
+      printerMissing: 'Принтер більше не налаштовано',
+      timelapseUnavailable: 'Не вдалося прочитати каталог таймлапсів',
+      ipcamUnavailable: 'Не вдалося прочитати каталог IP-камери',
+    },
     title: "Архіви друку",
     no3mfBanner: {
       title: "Деякі нещодавні роздруківки не вдалося заархівувати з мініатюрами",
@@ -3133,6 +3146,14 @@ export default {
 
   // Printer File Manager modal (printer internal storage)
   printerFiles: {
+    zipStarted_one: 'Розпочато завантаження ZIP для {{count}} файлу',
+    zipStarted_other: 'Розпочато завантаження ZIP для {{count}} файлів',
+    zipPartial: 'Розпочато завантаження ZIP із {{successful}} з {{total}} файлів; решту отримати не вдалося',
+    downloadFailed: 'Помилка завантаження: {{error}}',
+    unknownError: 'Невідома помилка',
+    shiftSelectHint: 'Клацніть із Shift, щоб вибрати діапазон',
+    selectFile: 'Вибрати {{name}}',
+    deselectFile: 'Зняти вибір із {{name}}',
     title: "Менеджер файлів",
     storageUsed: "Використовується:",
     storageFree: "Вільно:",
@@ -3142,6 +3163,7 @@ export default {
     deleteFileConfirm: "Видалити \"{{name}}\"? Це неможливо скасувати.",
     deleteFilesConfirm: "Видалити вибрані файли {{count}}? Це неможливо скасувати.",
     noFiles: "Немає файлів на принтері",
+    printerUnavailable: 'Файлова служба принтера недоступна. Повторіть спробу, коли принтер буде доступний.',
     loadingFiles: "Завантаження файлів...",
     failedToLoad: "Не вдалося завантажити файли",
     toast: {

+ 22 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -870,6 +870,19 @@ export default {
 
   // Archives page
   archives: {
+    media: {
+      title: '打印视频',
+      download: '下载打印视频',
+      searching: '正在检查打印机中的视频…',
+      searchFailed: '无法检查打印机中的视频。',
+      none: '未找到此打印任务的延时摄影或 IP 摄像头视频片段。',
+      attachedTimelapse: '已附加的延时摄影',
+      printerFiles: '仍保存在打印机上的文件',
+      downloadSelected: '下载所选文件',
+      printerMissing: '打印机已不在配置中',
+      timelapseUnavailable: '无法读取延时摄影目录',
+      ipcamUnavailable: '无法读取 IP 摄像头目录',
+    },
     title: '打印归档',
     no3mfBanner: {
       title: '最近的一些打印未能附带缩略图归档',
@@ -3093,6 +3106,14 @@ export default {
 
   // Printer File Manager modal (printer internal storage)
   printerFiles: {
+    zipStarted_one: '已开始下载 {{count}} 个文件的 ZIP',
+    zipStarted_other: '已开始下载 {{count}} 个文件的 ZIP',
+    zipPartial: '已开始下载 ZIP,共 {{total}} 个文件中成功获取 {{successful}} 个,其余文件无法获取',
+    downloadFailed: '下载失败:{{error}}',
+    unknownError: '未知错误',
+    shiftSelectHint: '按住 Shift 单击以选择范围',
+    selectFile: '选择 {{name}}',
+    deselectFile: '取消选择 {{name}}',
     title: '文件管理器',
     storageUsed: '已用:',
     storageFree: '剩余:',
@@ -3102,6 +3123,7 @@ export default {
     deleteFileConfirm: '删除"{{name}}"?此操作无法撤销。',
     deleteFilesConfirm: '删除 {{count}} 个选中的文件?此操作无法撤销。',
     noFiles: '打印机上没有文件',
+    printerUnavailable: '打印机文件服务不可用。请在打印机可访问时重试。',
     loadingFiles: '加载文件中...',
     failedToLoad: '加载文件失败',
     toast: {

+ 22 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -870,6 +870,19 @@ export default {
 
   // Archives page
   archives: {
+    media: {
+      title: '列印影片',
+      download: '下載列印影片',
+      searching: '正在檢查印表機中的影片…',
+      searchFailed: '無法檢查印表機中的影片。',
+      none: '找不到此列印工作的縮時攝影或 IP 攝影機影片片段。',
+      attachedTimelapse: '已附加的縮時攝影',
+      printerFiles: '仍保留在印表機上的檔案',
+      downloadSelected: '下載所選檔案',
+      printerMissing: '印表機已不在設定中',
+      timelapseUnavailable: '無法讀取縮時攝影目錄',
+      ipcamUnavailable: '無法讀取 IP 攝影機目錄',
+    },
     title: '列印歸檔',
     no3mfBanner: {
       title: '最近的一些列印未能附帶縮圖封存',
@@ -3093,6 +3106,14 @@ export default {
 
   // Printer File Manager modal (printer internal storage)
   printerFiles: {
+    zipStarted_one: '已開始下載 {{count}} 個檔案的 ZIP',
+    zipStarted_other: '已開始下載 {{count}} 個檔案的 ZIP',
+    zipPartial: '已開始下載 ZIP,共 {{total}} 個檔案中成功取得 {{successful}} 個,其餘檔案無法取得',
+    downloadFailed: '下載失敗:{{error}}',
+    unknownError: '未知錯誤',
+    shiftSelectHint: '按住 Shift 點擊以選取範圍',
+    selectFile: '選取 {{name}}',
+    deselectFile: '取消選取 {{name}}',
     title: '檔案管理器',
     storageUsed: '已用:',
     storageFree: '剩餘:',
@@ -3102,6 +3123,7 @@ export default {
     deleteFileConfirm: '刪除"{{name}}"?此操作無法復原。',
     deleteFilesConfirm: '刪除 {{count}} 個選中的檔案?此操作無法復原。',
     noFiles: '印表機上沒有檔案',
+    printerUnavailable: '印表機檔案服務無法使用。請在可連線至印表機時重試。',
     loadingFiles: '載入檔案中...',
     failedToLoad: '載入檔案失敗',
     toast: {

+ 50 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -89,6 +89,7 @@ import { QRCodeModal } from '../components/QRCodeModal';
 import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
 import { ProjectPageModal } from '../components/ProjectPageModal';
 import { TimelapseViewer } from '../components/TimelapseViewer';
+import { ArchiveMediaDownloadModal } from '../components/ArchiveMediaDownloadModal';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { TagManagementModal } from '../components/TagManagementModal';
@@ -342,6 +343,7 @@ function ArchiveCard({
   const [showEdit, setShowEdit] = useState(false);
   const [showPrintLog, setShowPrintLog] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
+  const [showPrinterMedia, setShowPrinterMedia] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
   const [showQRCode, setShowQRCode] = useState(false);
@@ -1381,6 +1383,20 @@ function ArchiveCard({
           >
             <Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.external_url && !archive.makerworld_url ? 'opacity-20' : ''}`} />
           </Button>
+          <Button
+            variant="secondary"
+            size="sm"
+            className="min-w-0 p-1 sm:p-1.5"
+            onClick={() => setShowPrinterMedia(true)}
+            disabled={!archive.timelapse_path && (
+              !hasPermission('printers:files') || !archive.printer_id || !archive.started_at
+            )}
+            title={!archive.timelapse_path && !hasPermission('printers:files')
+              ? t('printers.permission.noFiles')
+              : t('archives.media.download')}
+          >
+            <Film className="w-3 h-3 sm:w-4 sm:h-4" />
+          </Button>
           <Button
             variant="secondary"
             size="sm"
@@ -1569,6 +1585,16 @@ function ArchiveCard({
         />
       )}
 
+      {/* Print Media Download Modal */}
+      {showPrinterMedia && (
+        <ArchiveMediaDownloadModal
+          archiveId={archive.id}
+          archiveName={archive.print_name || archive.filename}
+          printerName={printerName}
+          onClose={() => setShowPrinterMedia(false)}
+        />
+      )}
+
       {/* Timelapse Viewer Modal */}
       {showTimelapse && archive.timelapse_path && (
         <TimelapseViewer
@@ -1770,6 +1796,7 @@ function ArchiveListRow({
   const [showSliceModal, setShowSliceModal] = useState(false);
   const [showRunPipeline, setShowRunPipeline] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
+  const [showPrinterMedia, setShowPrinterMedia] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
   const [showQRCode, setShowQRCode] = useState(false);
@@ -2395,6 +2422,19 @@ function ArchiveListRow({
               <Globe className="w-4 h-4" />
             </Button>
           )}
+          <Button
+            variant="ghost"
+            size="sm"
+            onClick={() => setShowPrinterMedia(true)}
+            disabled={!archive.timelapse_path && (
+              !hasPermission('printers:files') || !archive.printer_id || !archive.started_at
+            )}
+            title={!archive.timelapse_path && !hasPermission('printers:files')
+              ? t('printers.permission.noFiles')
+              : t('archives.media.download')}
+          >
+            <Film className="w-4 h-4" />
+          </Button>
           <Button
             variant="ghost"
             size="sm"
@@ -2601,6 +2641,16 @@ function ArchiveListRow({
         />
       )}
 
+      {/* Print Media Download Modal */}
+      {showPrinterMedia && (
+        <ArchiveMediaDownloadModal
+          archiveId={archive.id}
+          archiveName={archive.print_name || archive.filename}
+          printerName={printerName}
+          onClose={() => setShowPrinterMedia(false)}
+        />
+      )}
+
       {/* Timelapse Viewer Modal */}
       {showTimelapse && archive.timelapse_path && (
         <TimelapseViewer

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

@@ -6515,7 +6515,7 @@ function PrinterCard({
                   variant="secondary"
                   size="sm"
                   onClick={() => setShowFileManager(true)}
-                  disabled={!isConnected || !hasPermission('printers:files')}
+                  disabled={!hasPermission('printers:files')}
                   title={!hasPermission('printers:files') ? t('printers.permission.noFiles') : t('printers.browseFiles')}
                   className={footerIconButtonClass}
                 >

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
static/assets/index-DjndScv6.css


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-DoAzVOXb.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 1
static/assets/index-DynWy-72.css


+ 2 - 2
static/index.html

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

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff