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

Add printer video downloads and range selection (#2853)

maziggy 2 недель назад
Родитель
Сommit
89f3b1cc58
43 измененных файлов с 4046 добавлено и 184 удалено
  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


+ 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}
                 >

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


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


Разница между файлами не показана из-за своего большого размера
+ 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>

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