Browse Source

New Features:
- Print from File Manager with plate selection, AMS mapping, and print options. Closes #94
- Add to queue without creating archive (reduces clutter)
- Queue displays library file name, thumbnail, and print time

Fixes:
Queue items from File Manager now reference library files directly instead of
creating archives upfront. Archives are created automatically when prints start.

maziggy 4 months ago
parent
commit
02f6de4adc

+ 15 - 1
CHANGELOG.md

@@ -2,7 +2,7 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
-## [0.1.6b11] - 2026-01-20
+## [Unreleased]
 
 ### New Features
 - **Unified Print Modal** - Consolidated three separate modals into one unified component:
@@ -18,11 +18,25 @@ All notable changes to Bambuddy will be documented in this file.
 - **Enhanced Add-to-Queue** - Now includes plate selection and print options:
   - Configure all print settings upfront instead of editing afterward
   - Filament mapping with manual override capability
+- **Print from File Manager** - Full print configuration when printing from library files:
+  - Plate selection for multi-plate 3MF files with thumbnails
+  - Filament slot mapping with comparison to loaded filaments
+  - All print options (bed levelling, flow calibration, etc.)
+- **Deferred archive creation** - Queue items from File Manager no longer create archives upfront:
+  - Queue items store `library_file_id` directly
+  - Archives are created automatically when prints start
+  - Reduces clutter in Archives from unprinted queued files
+  - Queue displays library file name, thumbnail, and print time
+
+### Changed
+- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
+- **Edit Queue Item button** - Changed from "Print to X Printers" to "Save"
 
 ### Fixed
 - **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
   - Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
   - Folder navigation now works correctly without resetting
+- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
 
 ## [0.1.6b10] - 2026-01-20
 

+ 8 - 0
README.md

@@ -78,6 +78,14 @@
 - Auto power-on before print
 - Auto power-off after cooldown
 
+### 📁 File Manager (Library)
+- Upload and organize sliced files (3MF, gcode)
+- Folder structure with drag-and-drop
+- Print directly to any printer with full options
+- Add to queue without creating archive upfront
+- Plate selection for multi-plate 3MF files
+- Duplicate detection via file hash
+
 ### 📁 Projects
 - Group related prints (e.g., "Voron Build")
 - Track plates (print jobs) and parts separately

+ 508 - 22
backend/app/api/routes/library.py

@@ -30,6 +30,7 @@ from backend.app.schemas.library import (
     FileDuplicate,
     FileListResponse,
     FileMoveRequest,
+    FilePrintRequest,
     FileResponse as FileResponseSchema,
     FileUpdate,
     FileUploadResponse,
@@ -755,10 +756,7 @@ async def add_files_to_queue(
     """Add library files to the print queue.
 
     Only sliced files (.gcode or .gcode.3mf) can be added to the queue.
-    For each file:
-    1. Validates it's a sliced file
-    2. Creates an archive from the library file
-    3. Creates a queue item pointing to that archive
+    The archive will be created automatically when the print starts.
     """
     added: list[AddToQueueResult] = []
     errors: list[AddToQueueError] = []
@@ -771,8 +769,6 @@ async def add_files_to_queue(
     pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
     max_position = pos_result.scalar() or 0
 
-    archive_service = ArchiveService(db)
-
     for file_id in request.file_ids:
         lib_file = files.get(file_id)
 
@@ -792,7 +788,7 @@ async def add_files_to_queue(
             continue
 
         try:
-            # Get the full file path
+            # Verify file exists on disk
             file_path = Path(app_settings.base_dir) / lib_file.file_path
 
             if not file_path.exists():
@@ -801,23 +797,11 @@ async def add_files_to_queue(
                 )
                 continue
 
-            # Create archive from the library file
-            archive = await archive_service.archive_print(
-                printer_id=None,  # Unassigned
-                source_file=file_path,
-            )
-
-            if not archive:
-                errors.append(
-                    AddToQueueError(file_id=file_id, filename=lib_file.filename, error="Failed to create archive")
-                )
-                continue
-
-            # Create queue item
+            # Create queue item referencing library file (archive created at print start)
             max_position += 1
             queue_item = PrintQueueItem(
                 printer_id=None,  # Unassigned
-                archive_id=archive.id,
+                library_file_id=file_id,
                 position=max_position,
                 status="pending",
             )
@@ -830,7 +814,6 @@ async def add_files_to_queue(
                     file_id=file_id,
                     filename=lib_file.filename,
                     queue_item_id=queue_item.id,
-                    archive_id=archive.id,
                 )
             )
 
@@ -843,6 +826,509 @@ async def add_files_to_queue(
     return AddToQueueResponse(added=added, errors=errors)
 
 
+@router.get("/files/{file_id}/plates")
+async def get_library_file_plates(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get available plates from a multi-plate 3MF library file.
+
+    Returns a list of plates with their index, name, thumbnail availability,
+    and filament requirements. For single-plate exports, returns a single plate.
+    """
+    import xml.etree.ElementTree as ET
+    import zipfile
+
+    # Get the library file
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    lib_file = result.scalar_one_or_none()
+
+    if not lib_file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    file_path = Path(app_settings.base_dir) / lib_file.file_path
+    if not file_path.exists():
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    # Only 3MF files have plates
+    if not lib_file.filename.lower().endswith(".3mf"):
+        return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
+
+    plates = []
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            namelist = zf.namelist()
+
+            # Find all plate gcode files to determine available plates
+            gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
+
+            if not gcode_files:
+                # No sliced plates found
+                return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
+
+            # Extract plate indices from gcode filenames
+            plate_indices = []
+            for gf in gcode_files:
+                try:
+                    plate_str = gf[15:-6]  # Remove "Metadata/plate_" and ".gcode"
+                    plate_indices.append(int(plate_str))
+                except ValueError:
+                    pass
+
+            plate_indices.sort()
+
+            # Parse model_settings.config for plate names
+            plate_names = {}
+            if "Metadata/model_settings.config" in namelist:
+                try:
+                    model_content = zf.read("Metadata/model_settings.config").decode()
+                    model_root = ET.fromstring(model_content)
+                    for plate_elem in model_root.findall(".//plate"):
+                        plater_id = None
+                        plater_name = None
+                        for meta in plate_elem.findall("metadata"):
+                            key = meta.get("key")
+                            value = meta.get("value")
+                            if key == "plater_id" and value:
+                                try:
+                                    plater_id = int(value)
+                                except ValueError:
+                                    pass
+                            elif key == "plater_name" and value:
+                                plater_name = value.strip()
+                        if plater_id is not None and plater_name:
+                            plate_names[plater_id] = plater_name
+                except Exception:
+                    pass
+
+            # Parse slice_info.config for plate metadata
+            plate_metadata = {}
+            if "Metadata/slice_info.config" in namelist:
+                content = zf.read("Metadata/slice_info.config").decode()
+                root = ET.fromstring(content)
+
+                for plate_elem in root.findall(".//plate"):
+                    plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
+
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        key = meta.get("key")
+                        value = meta.get("value")
+                        if key == "index" and value:
+                            try:
+                                plate_index = int(value)
+                            except ValueError:
+                                pass
+                        elif key == "prediction" and value:
+                            try:
+                                plate_info["prediction"] = int(value)
+                            except ValueError:
+                                pass
+                        elif key == "weight" and value:
+                            try:
+                                plate_info["weight"] = float(value)
+                            except ValueError:
+                                pass
+
+                    # Get filaments used in this plate
+                    for filament_elem in plate_elem.findall("filament"):
+                        filament_id = filament_elem.get("id")
+                        filament_type = filament_elem.get("type", "")
+                        filament_color = filament_elem.get("color", "")
+                        used_g = filament_elem.get("used_g", "0")
+                        used_m = filament_elem.get("used_m", "0")
+
+                        try:
+                            used_grams = float(used_g)
+                        except (ValueError, TypeError):
+                            used_grams = 0
+
+                        if used_grams > 0 and filament_id:
+                            plate_info["filaments"].append(
+                                {
+                                    "slot_id": int(filament_id),
+                                    "type": filament_type,
+                                    "color": filament_color,
+                                    "used_grams": round(used_grams, 1),
+                                    "used_meters": float(used_m) if used_m else 0,
+                                }
+                            )
+
+                    plate_info["filaments"].sort(key=lambda x: x["slot_id"])
+
+                    # Collect object names
+                    for obj_elem in plate_elem.findall("object"):
+                        obj_name = obj_elem.get("name")
+                        if obj_name and obj_name not in plate_info["objects"]:
+                            plate_info["objects"].append(obj_name)
+
+                    # Set plate name
+                    if plate_index is not None:
+                        custom_name = plate_names.get(plate_index)
+                        if custom_name:
+                            plate_info["name"] = custom_name
+                        elif plate_info["objects"]:
+                            plate_info["name"] = plate_info["objects"][0]
+                        plate_metadata[plate_index] = plate_info
+
+            # Build plate list
+            for idx in plate_indices:
+                meta = plate_metadata.get(idx, {})
+                has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
+
+                plates.append(
+                    {
+                        "index": idx,
+                        "name": meta.get("name"),
+                        "objects": meta.get("objects", []),
+                        "has_thumbnail": has_thumbnail,
+                        "thumbnail_url": f"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}"
+                        if has_thumbnail
+                        else None,
+                        "print_time_seconds": meta.get("prediction"),
+                        "filament_used_grams": meta.get("weight"),
+                        "filaments": meta.get("filaments", []),
+                    }
+                )
+
+    except Exception as e:
+        logger.warning(f"Failed to parse plates from library file {file_id}: {e}")
+
+    return {
+        "file_id": file_id,
+        "filename": lib_file.filename,
+        "plates": plates,
+        "is_multi_plate": len(plates) > 1,
+    }
+
+
+@router.get("/files/{file_id}/plate-thumbnail/{plate_index}")
+async def get_library_file_plate_thumbnail(
+    file_id: int,
+    plate_index: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the thumbnail image for a specific plate from a library file."""
+    import zipfile
+
+    from starlette.responses import Response
+
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    lib_file = result.scalar_one_or_none()
+
+    if not lib_file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    file_path = Path(app_settings.base_dir) / lib_file.file_path
+    if not file_path.exists():
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            thumb_path = f"Metadata/plate_{plate_index}.png"
+            if thumb_path in zf.namelist():
+                data = zf.read(thumb_path)
+                return Response(content=data, media_type="image/png")
+    except Exception:
+        pass
+
+    raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
+
+
+@router.get("/files/{file_id}/filament-requirements")
+async def get_library_file_filament_requirements(
+    file_id: int,
+    plate_id: int | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get filament requirements from a library file.
+
+    Parses the 3MF file to extract filament slot IDs, types, colors, and usage.
+    This enables AMS slot assignment when printing from the file manager.
+
+    Args:
+        file_id: The library file ID
+        plate_id: Optional plate index to get filaments for a specific plate
+    """
+    import xml.etree.ElementTree as ET
+    import zipfile
+
+    # Get the library file
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    lib_file = result.scalar_one_or_none()
+
+    if not lib_file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    # Get the full file path
+    file_path = Path(app_settings.base_dir) / lib_file.file_path
+
+    if not file_path.exists():
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    # Only 3MF files have parseable filament info
+    if not lib_file.filename.lower().endswith(".3mf"):
+        return {"file_id": file_id, "filename": lib_file.filename, "plate_id": plate_id, "filaments": []}
+
+    filaments = []
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            # Parse slice_info.config for filament requirements
+            if "Metadata/slice_info.config" in zf.namelist():
+                content = zf.read("Metadata/slice_info.config").decode()
+                root = ET.fromstring(content)
+
+                if plate_id is not None:
+                    # Find filaments for specific plate
+                    for plate_elem in root.findall(".//plate"):
+                        # Check if this is the requested plate
+                        plate_index = None
+                        for meta in plate_elem.findall("metadata"):
+                            if meta.get("key") == "index":
+                                try:
+                                    plate_index = int(meta.get("value", ""))
+                                except ValueError:
+                                    pass
+                                break
+
+                        if plate_index == plate_id:
+                            # Extract filaments from this plate
+                            for filament_elem in plate_elem.findall("filament"):
+                                filament_id = filament_elem.get("id")
+                                filament_type = filament_elem.get("type", "")
+                                filament_color = filament_elem.get("color", "")
+                                used_g = filament_elem.get("used_g", "0")
+                                used_m = filament_elem.get("used_m", "0")
+
+                                try:
+                                    used_grams = float(used_g)
+                                except (ValueError, TypeError):
+                                    used_grams = 0
+
+                                if used_grams > 0 and filament_id:
+                                    filaments.append(
+                                        {
+                                            "slot_id": int(filament_id),
+                                            "type": filament_type,
+                                            "color": filament_color,
+                                            "used_grams": round(used_grams, 1),
+                                            "used_meters": float(used_m) if used_m else 0,
+                                        }
+                                    )
+                            break
+                else:
+                    # Extract all filaments with used_g > 0 (for single-plate or overview)
+                    for filament_elem in root.findall(".//filament"):
+                        filament_id = filament_elem.get("id")
+                        filament_type = filament_elem.get("type", "")
+                        filament_color = filament_elem.get("color", "")
+                        used_g = filament_elem.get("used_g", "0")
+                        used_m = filament_elem.get("used_m", "0")
+
+                        try:
+                            used_grams = float(used_g)
+                        except (ValueError, TypeError):
+                            used_grams = 0
+
+                        if used_grams > 0 and filament_id:
+                            filaments.append(
+                                {
+                                    "slot_id": int(filament_id),
+                                    "type": filament_type,
+                                    "color": filament_color,
+                                    "used_grams": round(used_grams, 1),
+                                    "used_meters": float(used_m) if used_m else 0,
+                                }
+                            )
+
+            # Sort by slot ID
+            filaments.sort(key=lambda x: x["slot_id"])
+
+    except Exception as e:
+        logger.warning(f"Failed to parse filament requirements from library file {file_id}: {e}")
+
+    return {
+        "file_id": file_id,
+        "filename": lib_file.filename,
+        "plate_id": plate_id,
+        "filaments": filaments,
+    }
+
+
+@router.post("/files/{file_id}/print")
+async def print_library_file(
+    file_id: int,
+    printer_id: int,
+    body: FilePrintRequest | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Print a library file directly.
+
+    This endpoint:
+    1. Creates an archive from the library file
+    2. Uploads the file to the printer
+    3. Starts the print
+
+    Only sliced files (.gcode or .gcode.3mf) can be printed.
+    """
+    import zipfile
+
+    from backend.app.main import register_expected_print
+    from backend.app.models.printer import Printer
+    from backend.app.services.bambu_ftp import (
+        delete_file_async,
+        get_ftp_retry_settings,
+        upload_file_async,
+        with_ftp_retry,
+    )
+    from backend.app.services.printer_manager import printer_manager
+
+    # Use defaults if no body provided
+    if body is None:
+        body = FilePrintRequest()
+
+    # Get the library file
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    lib_file = result.scalar_one_or_none()
+
+    if not lib_file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    # Validate file is sliced
+    if not is_sliced_file(lib_file.filename):
+        raise HTTPException(
+            status_code=400,
+            detail="Not a sliced file. Only .gcode or .gcode.3mf files can be printed.",
+        )
+
+    # Get the full file path
+    file_path = Path(app_settings.base_dir) / lib_file.file_path
+
+    if not file_path.exists():
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    # Get printer
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+
+    # Check printer is connected
+    if not printer_manager.is_connected(printer_id):
+        raise HTTPException(status_code=400, detail="Printer is not connected")
+
+    # Create archive from the library file
+    archive_service = ArchiveService(db)
+    archive = await archive_service.archive_print(
+        printer_id=printer_id,
+        source_file=file_path,
+    )
+
+    if not archive:
+        raise HTTPException(status_code=500, detail="Failed to create archive")
+
+    await db.flush()
+
+    # Prepare remote filename
+    base_name = lib_file.filename
+    if base_name.endswith(".gcode.3mf"):
+        base_name = base_name[:-10]
+    elif base_name.endswith(".3mf"):
+        base_name = base_name[:-4]
+    remote_filename = f"{base_name}.3mf"
+    remote_path = f"/{remote_filename}"
+
+    # Get FTP retry settings
+    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+
+    # Delete existing file if present (avoids 553 error)
+    await delete_file_async(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        socket_timeout=ftp_timeout,
+        printer_model=printer.model,
+    )
+
+    # Upload file to printer
+    if ftp_retry_enabled:
+        uploaded = await with_ftp_retry(
+            upload_file_async,
+            printer.ip_address,
+            printer.access_code,
+            file_path,
+            remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
+            max_retries=ftp_retry_count,
+            retry_delay=ftp_retry_delay,
+            operation_name=f"Upload for print to {printer.name}",
+        )
+    else:
+        uploaded = await upload_file_async(
+            printer.ip_address,
+            printer.access_code,
+            file_path,
+            remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
+        )
+
+    if not uploaded:
+        raise HTTPException(status_code=500, detail="Failed to upload file to printer")
+
+    # Register this as an expected print so we don't create a duplicate archive
+    register_expected_print(printer_id, remote_filename, archive.id)
+
+    # Determine plate ID
+    if body.plate_id is not None:
+        plate_id = body.plate_id
+    else:
+        plate_id = 1
+        try:
+            with zipfile.ZipFile(file_path, "r") as zf:
+                for name in zf.namelist():
+                    if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
+                        plate_str = name[15:-6]
+                        plate_id = int(plate_str)
+                        break
+        except Exception:
+            pass
+
+    logger.info(
+        f"Print library file {file_id}: archive_id={archive.id}, plate_id={plate_id}, "
+        f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}"
+    )
+
+    # Start the print
+    started = printer_manager.start_print(
+        printer_id,
+        remote_filename,
+        plate_id,
+        ams_mapping=body.ams_mapping,
+        timelapse=body.timelapse,
+        bed_levelling=body.bed_levelling,
+        flow_cali=body.flow_cali,
+        vibration_cali=body.vibration_cali,
+        layer_inspect=body.layer_inspect,
+        use_ams=body.use_ams,
+    )
+
+    if not started:
+        raise HTTPException(status_code=500, detail="Failed to start print")
+
+    await db.commit()
+
+    return {
+        "status": "printing",
+        "printer_id": printer_id,
+        "archive_id": archive.id,
+        "filename": lib_file.filename,
+    }
+
+
 # ============ File Detail Endpoints ============
 
 

+ 44 - 11
backend/app/api/routes/print_queue.py

@@ -11,6 +11,7 @@ from sqlalchemy.orm import selectinload
 
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.schemas.print_queue import (
@@ -26,7 +27,7 @@ router = APIRouter(prefix="/queue", tags=["queue"])
 
 
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
-    """Add nested archive/printer info to response."""
+    """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
     ams_mapping_parsed = None
     if item.ams_mapping:
@@ -40,6 +41,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "id": item.id,
         "printer_id": item.printer_id,
         "archive_id": item.archive_id,
+        "library_file_id": item.library_file_id,
         "position": item.position,
         "scheduled_time": item.scheduled_time,
         "require_previous_success": item.require_previous_success,
@@ -64,6 +66,16 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
         response.print_time_seconds = item.archive.print_time_seconds
+    if item.library_file:
+        response.library_file_name = (
+            item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
+        )
+        if not response.library_file_name:
+            response.library_file_name = item.library_file.filename
+        response.library_file_thumbnail = item.library_file.thumbnail_path
+        # Get print time from library file metadata if no archive
+        if not item.archive and item.library_file.file_metadata:
+            response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
     if item.printer:
         response.printer_name = item.printer.name
     return response
@@ -78,7 +90,11 @@ async def list_queue(
     """List all queue items, optionally filtered by printer or status."""
     query = (
         select(PrintQueueItem)
-        .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
+        .options(
+            selectinload(PrintQueueItem.archive),
+            selectinload(PrintQueueItem.printer),
+            selectinload(PrintQueueItem.library_file),
+        )
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
 
@@ -102,16 +118,27 @@ async def add_to_queue(
     db: AsyncSession = Depends(get_db),
 ):
     """Add an item to the print queue."""
+    # Validate that either archive_id or library_file_id is provided
+    if not data.archive_id and not data.library_file_id:
+        raise HTTPException(400, "Either archive_id or library_file_id must be provided")
+
     # Validate printer exists (if assigned)
     if data.printer_id is not None:
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
 
-    # Validate archive exists
-    result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
-    if not result.scalar_one_or_none():
-        raise HTTPException(400, "Archive not found")
+    # Validate archive exists (if provided)
+    if data.archive_id:
+        result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
+        if not result.scalar_one_or_none():
+            raise HTTPException(400, "Archive not found")
+
+    # Validate library file exists (if provided)
+    if data.library_file_id:
+        result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id))
+        if not result.scalar_one_or_none():
+            raise HTTPException(400, "Library file not found")
 
     # Get next position for this printer (or for unassigned items)
     if data.printer_id is not None:
@@ -132,6 +159,7 @@ async def add_to_queue(
     item = PrintQueueItem(
         printer_id=data.printer_id,
         archive_id=data.archive_id,
+        library_file_id=data.library_file_id,
         scheduled_time=data.scheduled_time,
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
@@ -152,9 +180,10 @@ async def add_to_queue(
     await db.refresh(item)
 
     # Load relationships for response
-    await db.refresh(item, ["archive", "printer"])
+    await db.refresh(item, ["archive", "printer", "library_file"])
 
-    logger.info(f"Added archive {data.archive_id} to queue for printer {data.printer_id or 'unassigned'}")
+    source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
+    logger.info(f"Added {source_name} to queue for printer {data.printer_id or 'unassigned'}")
 
     # MQTT relay - publish queue job added
     try:
@@ -177,7 +206,11 @@ async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific queue item."""
     result = await db.execute(
         select(PrintQueueItem)
-        .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
+        .options(
+            selectinload(PrintQueueItem.archive),
+            selectinload(PrintQueueItem.printer),
+            selectinload(PrintQueueItem.library_file),
+        )
         .where(PrintQueueItem.id == item_id)
     )
     item = result.scalar_one_or_none()
@@ -217,7 +250,7 @@ async def update_queue_item(
         setattr(item, field, value)
 
     await db.commit()
-    await db.refresh(item, ["archive", "printer"])
+    await db.refresh(item, ["archive", "printer", "library_file"])
 
     logger.info(f"Updated queue item {item_id}")
     return _enrich_response(item)
@@ -372,7 +405,7 @@ async def start_queue_item(
     # Clear manual_start flag so scheduler picks it up
     item.manual_start = False
     await db.commit()
-    await db.refresh(item, ["archive", "printer"])
+    await db.refresh(item, ["archive", "printer", "library_file"])
 
     logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
     return _enrich_response(item)

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

@@ -579,6 +579,68 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add library_file_id column to print_queue and make archive_id nullable
+    # This allows queue items to reference library files directly (archive created at print start)
+    try:
+        await conn.execute(
+            text(
+                "ALTER TABLE print_queue ADD COLUMN library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE"
+            )
+        )
+    except Exception:
+        pass
+
+    # Check if archive_id needs to be made nullable (requires table recreation in SQLite)
+    try:
+        result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
+        row = result.fetchone()
+        if row and "archive_id INTEGER NOT NULL" in (row[0] or ""):
+            # Need to migrate - archive_id is currently NOT NULL
+            await conn.execute(
+                text("""
+                CREATE TABLE print_queue_new2 (
+                    id INTEGER PRIMARY KEY,
+                    printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
+                    archive_id INTEGER REFERENCES print_archives(id) ON DELETE CASCADE,
+                    library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE,
+                    project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
+                    position INTEGER DEFAULT 0,
+                    scheduled_time DATETIME,
+                    manual_start BOOLEAN DEFAULT 0,
+                    require_previous_success BOOLEAN DEFAULT 0,
+                    auto_off_after BOOLEAN DEFAULT 0,
+                    ams_mapping TEXT,
+                    plate_id INTEGER,
+                    bed_levelling BOOLEAN DEFAULT 1,
+                    flow_cali BOOLEAN DEFAULT 0,
+                    vibration_cali BOOLEAN DEFAULT 1,
+                    layer_inspect BOOLEAN DEFAULT 0,
+                    timelapse BOOLEAN DEFAULT 0,
+                    use_ams BOOLEAN DEFAULT 1,
+                    status VARCHAR(20) DEFAULT 'pending',
+                    started_at DATETIME,
+                    completed_at DATETIME,
+                    error_message TEXT,
+                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+                )
+            """)
+            )
+            await conn.execute(
+                text("""
+                INSERT INTO print_queue_new2
+                SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
+                       manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
+                       COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
+                       COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
+                       status, started_at, completed_at, error_message, created_at
+                FROM print_queue
+            """)
+            )
+            await conn.execute(text("DROP TABLE print_queue"))
+            await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 8 - 2
backend/app/models/print_queue.py

@@ -15,7 +15,11 @@ class PrintQueueItem(Base):
 
     # Links
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
-    archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"))
+    # Either archive_id OR library_file_id must be set (archive created at print start from library file)
+    archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
+    library_file_id: Mapped[int | None] = mapped_column(
+        ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
+    )
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
 
     # Scheduling
@@ -57,10 +61,12 @@ class PrintQueueItem(Base):
 
     # Relationships
     printer: Mapped["Printer"] = relationship()
-    archive: Mapped["PrintArchive"] = relationship()
+    archive: Mapped["PrintArchive | None"] = relationship()
+    library_file: Mapped["LibraryFile | None"] = relationship()
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.library import LibraryFile  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.project import Project  # noqa: E402

+ 3 - 3
backend/app/schemas/library.py

@@ -159,9 +159,10 @@ class FileMoveRequest(BaseModel):
 
 
 class FilePrintRequest(BaseModel):
-    """Schema for printing a file from the library."""
+    """Schema for printing a file from the library.
 
-    printer_id: str  # Printer serial number
+    Note: printer_id is passed as a query parameter, not in the body.
+    """
 
     # Print options (same as archive reprint)
     plate_id: int | None = None
@@ -218,7 +219,6 @@ class AddToQueueResult(BaseModel):
     file_id: int
     filename: str
     queue_item_id: int
-    archive_id: int
 
 
 class AddToQueueError(BaseModel):

+ 8 - 3
backend/app/schemas/print_queue.py

@@ -17,7 +17,9 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
 
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
-    archive_id: int
+    # Either archive_id OR library_file_id must be provided
+    archive_id: int | None = None
+    library_file_id: int | None = None
     scheduled_time: datetime | None = None  # None = ASAP (next when idle)
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
@@ -57,7 +59,8 @@ class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemResponse(BaseModel):
     id: int
     printer_id: int | None  # None = unassigned
-    archive_id: int
+    archive_id: int | None  # None if library_file_id is set (archive created at print start)
+    library_file_id: int | None  # For queue items from library files
     position: int
     scheduled_time: UTCDatetime
     require_previous_success: bool
@@ -81,8 +84,10 @@ class PrintQueueItemResponse(BaseModel):
     # Nested info for UI (populated in route)
     archive_name: str | None = None
     archive_thumbnail: str | None = None
+    library_file_name: str | None = None  # Name of library file (if library_file_id is set)
+    library_file_thumbnail: str | None = None  # Thumbnail of library file
     printer_name: str | None = None
-    print_time_seconds: int | None = None  # Estimated print time from archive
+    print_time_seconds: int | None = None  # Estimated print time from archive or library file
 
     class Config:
         from_attributes = True

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

@@ -540,3 +540,197 @@ class TestQueueCancelEndpoint:
 
         response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
         assert response.status_code == 400
+
+
+class TestQueueLibraryFileSupport:
+    """Tests for queue items with library_file_id (instead of archive_id)."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Library Test Printer {counter}",
+                "ip_address": f"192.168.1.{150 + counter}",
+                "serial_number": f"TESTLIB{counter:04d}",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def library_file_factory(self, db_session):
+        """Factory to create test library files."""
+        _counter = [0]
+
+        async def _create_library_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"library_test_{counter}.3mf",
+                "file_path": f"/test/library/library_test_{counter}.3mf",
+                "file_size": 2048,
+                "file_type": "3mf",
+                "file_metadata": {"print_name": f"Library Print {counter}", "print_time_seconds": 3600},
+            }
+            defaults.update(kwargs)
+
+            lib_file = LibraryFile(**defaults)
+            db_session.add(lib_file)
+            await db_session.commit()
+            await db_session.refresh(lib_file)
+            return lib_file
+
+        return _create_library_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_library_file(
+        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
+    ):
+        """Verify item can be added to queue using library_file_id instead of archive_id."""
+        printer = await printer_factory()
+        lib_file = await library_file_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "library_file_id": lib_file.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+        assert result["library_file_id"] == lib_file.id
+        assert result["archive_id"] is None
+        assert result["status"] == "pending"
+        assert result["library_file_name"] == "Library Print 1"
+        assert result["print_time_seconds"] == 3600
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_library_file_with_options(
+        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
+    ):
+        """Verify library file queue item can have all options set."""
+        printer = await printer_factory()
+        lib_file = await library_file_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "library_file_id": lib_file.id,
+            "ams_mapping": [1, 2, -1, -1],
+            "plate_id": 2,
+            "bed_levelling": False,
+            "timelapse": True,
+            "manual_start": True,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["library_file_id"] == lib_file.id
+        assert result["ams_mapping"] == [1, 2, -1, -1]
+        assert result["plate_id"] == 2
+        assert result["bed_levelling"] is False
+        assert result["timelapse"] is True
+        assert result["manual_start"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_requires_archive_or_library_file(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify 400 error when neither archive_id nor library_file_id provided."""
+        printer = await printer_factory()
+
+        data = {
+            "printer_id": printer.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 400
+        assert (
+            "archive_id" in response.json()["detail"].lower() or "library_file_id" in response.json()["detail"].lower()
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_queue_item_with_library_file(
+        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
+    ):
+        """Verify queue item with library_file_id can be updated."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        lib_file = await library_file_factory()
+
+        # Create queue item directly
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            library_file_id=lib_file.id,
+            status="pending",
+            position=1,
+        )
+        db_session.add(item)
+        await db_session.commit()
+        await db_session.refresh(item)
+
+        # Update the item
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            json={"auto_off_after": True, "plate_id": 3},
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["auto_off_after"] is True
+        assert result["plate_id"] == 3
+        assert result["library_file_id"] == lib_file.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_queue_includes_library_file_info(
+        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
+    ):
+        """Verify queue list includes library file metadata."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        lib_file = await library_file_factory(
+            file_metadata={"print_name": "Custom Print Name", "print_time_seconds": 7200}
+        )
+
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            library_file_id=lib_file.id,
+            status="pending",
+            position=1,
+        )
+        db_session.add(item)
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/queue/")
+        assert response.status_code == 200
+        items = response.json()
+        assert len(items) >= 1
+
+        # Find our item
+        our_item = next((i for i in items if i["library_file_id"] == lib_file.id), None)
+        assert our_item is not None
+        assert our_item["library_file_name"] == "Custom Print Name"
+        assert our_item["print_time_seconds"] == 7200

+ 64 - 3
frontend/src/api/client.ts

@@ -845,7 +845,9 @@ export interface DiscoveredTasmotaDevice {
 export interface PrintQueueItem {
   id: number;
   printer_id: number | null;  // null = unassigned
-  archive_id: number;
+  // Either archive_id OR library_file_id must be set (archive created at print start)
+  archive_id: number | null;
+  library_file_id: number | null;
   position: number;
   scheduled_time: string | null;
   require_previous_success: boolean;
@@ -867,13 +869,17 @@ export interface PrintQueueItem {
   created_at: string;
   archive_name?: string | null;
   archive_thumbnail?: string | null;
+  library_file_name?: string | null;
+  library_file_thumbnail?: string | null;
   printer_name?: string | null;
-  print_time_seconds?: number | null;  // Estimated print time from archive
+  print_time_seconds?: number | null;  // Estimated print time from archive or library file
 }
 
 export interface PrintQueueItemCreate {
   printer_id?: number | null;  // null = unassigned
-  archive_id: number;
+  // Either archive_id OR library_file_id must be provided
+  archive_id?: number | null;
+  library_file_id?: number | null;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   auto_off_after?: boolean;
@@ -2594,6 +2600,61 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ file_ids: fileIds }),
     }),
+  printLibraryFile: (
+    fileId: number,
+    printerId: number,
+    options?: {
+      plate_id?: number;
+      ams_mapping?: number[];
+      bed_levelling?: boolean;
+      flow_cali?: boolean;
+      vibration_cali?: boolean;
+      layer_inspect?: boolean;
+      timelapse?: boolean;
+      use_ams?: boolean;
+    }
+  ) =>
+    request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
+      `/library/files/${fileId}/print?printer_id=${printerId}`,
+      {
+        method: 'POST',
+        body: options ? JSON.stringify(options) : undefined,
+      }
+    ),
+  getLibraryFilePlates: (fileId: number) =>
+    request<{
+      file_id: number;
+      filename: string;
+      plates: Array<{
+        index: number;
+        name: string | null;
+        objects: string[];
+        has_thumbnail: boolean;
+        thumbnail_url: string | null;
+        print_time_seconds: number | null;
+        filament_used_grams: number | null;
+        filaments: Array<{
+          slot_id: number;
+          type: string;
+          color: string;
+          used_grams: number;
+          used_meters: number;
+        }>;
+      }>;
+      is_multi_plate: boolean;
+    }>(`/library/files/${fileId}/plates`),
+  getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
+    request<{
+      file_id: number;
+      filename: string;
+      filaments: Array<{
+        slot_id: number;
+        type: string;
+        color: string;
+        used_grams: number;
+        used_meters: number;
+      }>;
+    }>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),
 };
 
 // AMS History types

+ 1 - 10
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -12,9 +12,7 @@ import type { FilamentMappingProps } from './types';
  */
 export function FilamentMapping({
   printerId,
-  archiveId,
-  selectedPlate,
-  isMultiPlate,
+  filamentReqs,
   manualMappings,
   onManualMappingChange,
 }: FilamentMappingProps) {
@@ -22,13 +20,6 @@ export function FilamentMapping({
   const [isRefreshing, setIsRefreshing] = useState(false);
   const [isExpanded, setIsExpanded] = useState(false);
 
-  // Fetch filament requirements from the archived 3MF (filtered by plate if selected)
-  const { data: filamentReqs } = useQuery({
-    queryKey: ['archive-filaments', archiveId, selectedPlate],
-    queryFn: () => api.getArchiveFilamentRequirements(archiveId, selectedPlate ?? undefined),
-    enabled: selectedPlate !== null || !isMultiPlate,
-  });
-
   // Fetch printer status
   const { data: printerStatus } = useQuery({
     queryKey: ['printer-status', printerId],

+ 33 - 69
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -2,21 +2,19 @@ import { Printer as PrinterIcon, Loader2, AlertCircle, Check } from 'lucide-reac
 import type { PrinterSelectorProps } from './types';
 
 /**
- * Printer selection component with multiple modes:
- * - Grid mode (default): Shows printers as selectable cards (single or multi-select)
- * - Dropdown mode: Shows printers in a select dropdown (used when allowUnassigned is true)
+ * Printer selection component with grid-based UI.
+ * Supports single or multi-select modes.
  */
 export function PrinterSelector({
   printers,
-  selectedPrinterId,
-  selectedPrinterIds = [],
-  onSelect,
+  selectedPrinterIds,
   onMultiSelect,
   isLoading = false,
-  allowUnassigned = false,
   allowMultiple = false,
+  showInactive = false,
 }: PrinterSelectorProps) {
-  const activePrinters = printers.filter((p) => p.is_active);
+  // Filter printers based on showInactive flag
+  const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
 
   if (isLoading) {
     return (
@@ -26,53 +24,17 @@ export function PrinterSelector({
     );
   }
 
-  // Use dropdown mode for edit scenarios (allows unassigning printer)
-  if (allowUnassigned) {
+  if (displayPrinters.length === 0) {
     return (
-      <div>
-        <label className="block text-sm text-bambu-gray mb-1">Printer</label>
-        {printers.length === 0 ? (
-          <div className="flex items-center gap-2 text-red-400 text-sm">
-            <AlertCircle className="w-4 h-4" />
-            No printers configured
-          </div>
-        ) : (
-          <>
-            <select
-              className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:border-bambu-green focus:outline-none ${
-                selectedPrinterId === null ? 'border-orange-400' : 'border-bambu-dark-tertiary'
-              }`}
-              value={selectedPrinterId ?? ''}
-              onChange={(e) => onSelect(e.target.value ? Number(e.target.value) : null)}
-            >
-              <option value="">-- Select a printer --</option>
-              {printers.map((p) => (
-                <option key={p.id} value={p.id}>
-                  {p.name}
-                </option>
-              ))}
-            </select>
-            {selectedPrinterId === null && (
-              <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
-                <AlertCircle className="w-3 h-3" />
-                Assign a printer to enable printing
-              </p>
-            )}
-          </>
-        )}
+      <div className="flex items-center gap-2 text-red-400 text-sm mb-4">
+        <AlertCircle className="w-4 h-4" />
+        No {showInactive ? '' : 'active '}printers available
       </div>
     );
   }
 
-  // Grid mode for reprint/add-to-queue (only active printers)
-  if (activePrinters.length === 0) {
-    return (
-      <div className="text-center py-8 text-bambu-gray">No active printers available</div>
-    );
-  }
-
   const handlePrinterClick = (printerId: number) => {
-    if (allowMultiple && onMultiSelect) {
+    if (allowMultiple) {
       // Multi-select mode: toggle printer in selection
       if (selectedPrinterIds.includes(printerId)) {
         onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
@@ -80,36 +42,27 @@ export function PrinterSelector({
         onMultiSelect([...selectedPrinterIds, printerId]);
       }
     } else {
-      // Single-select mode
-      onSelect(printerId);
+      // Single-select mode: replace selection
+      onMultiSelect([printerId]);
     }
   };
 
   const handleSelectAll = () => {
-    if (onMultiSelect) {
-      onMultiSelect(activePrinters.map((p) => p.id));
-    }
+    onMultiSelect(displayPrinters.map((p) => p.id));
   };
 
   const handleDeselectAll = () => {
-    if (onMultiSelect) {
-      onMultiSelect([]);
-    }
+    onMultiSelect([]);
   };
 
-  const isSelected = (printerId: number) => {
-    if (allowMultiple) {
-      return selectedPrinterIds.includes(printerId);
-    }
-    return selectedPrinterId === printerId;
-  };
+  const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);
 
-  const selectedCount = allowMultiple ? selectedPrinterIds.length : (selectedPrinterId ? 1 : 0);
+  const selectedCount = selectedPrinterIds.length;
 
   return (
     <div className="space-y-2 mb-6">
       {/* Multi-select header */}
-      {allowMultiple && activePrinters.length > 1 && (
+      {allowMultiple && displayPrinters.length > 1 && (
         <div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
           <span>
             {selectedCount === 0
@@ -117,7 +70,7 @@ export function PrinterSelector({
               : `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
           </span>
           <div className="flex gap-2">
-            {selectedCount < activePrinters.length && (
+            {selectedCount < displayPrinters.length && (
               <button
                 type="button"
                 onClick={handleSelectAll}
@@ -139,7 +92,7 @@ export function PrinterSelector({
         </div>
       )}
 
-      {activePrinters.map((printer) => (
+      {displayPrinters.map((printer) => (
         <button
           key={printer.id}
           type="button"
@@ -148,7 +101,7 @@ export function PrinterSelector({
             isSelected(printer.id)
               ? 'border-bambu-green bg-bambu-green/10'
               : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
-          }`}
+          } ${!printer.is_active ? 'opacity-60' : ''}`}
         >
           <div
             className={`p-2 rounded-lg ${
@@ -162,7 +115,10 @@ export function PrinterSelector({
             />
           </div>
           <div className="text-left flex-1">
-            <p className="text-white font-medium">{printer.name}</p>
+            <p className="text-white font-medium">
+              {printer.name}
+              {!printer.is_active && <span className="text-bambu-gray text-xs ml-2">(inactive)</span>}
+            </p>
             <p className="text-xs text-bambu-gray">
               {printer.model || 'Unknown model'} • {printer.ip_address}
             </p>
@@ -181,6 +137,14 @@ export function PrinterSelector({
           )}
         </button>
       ))}
+
+      {/* Warning when no printer selected */}
+      {selectedCount === 0 && (
+        <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
+          <AlertCircle className="w-3 h-3" />
+          Select at least one printer
+        </p>
+      )}
     </div>
   );
 }

+ 138 - 96
frontend/src/components/PrintModal/index.tsx

@@ -23,13 +23,17 @@ import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
 
 /**
  * Unified PrintModal component that handles three modes:
- * - 'reprint': Immediate print from archive (supports multi-printer)
- * - 'add-to-queue': Schedule print to queue (supports multi-printer)
- * - 'edit-queue-item': Edit existing queue item (single printer only)
+ * - 'reprint': Immediate print from archive or library file (supports multi-printer)
+ * - 'add-to-queue': Schedule print to queue from archive or library file (supports multi-printer)
+ * - 'edit-queue-item': Edit existing queue item (supports multi-printer)
+ *
+ * Both archiveId and libraryFileId are supported. Library files can be printed immediately
+ * or added to queue (archive is created at print start time, not when queued).
  */
 export function PrintModal({
   mode,
   archiveId,
+  libraryFileId,
   archiveName,
   queueItem,
   onClose,
@@ -38,17 +42,18 @@ export function PrintModal({
   const queryClient = useQueryClient();
   const { showToast } = useToast();
 
-  // Single printer selection (for edit mode and backward compatibility)
-  const [selectedPrinter, setSelectedPrinter] = useState<number | null>(() => {
-    if (mode === 'edit-queue-item' && queueItem) {
-      return queueItem.printer_id;
+  // Determine if we're printing a library file
+  const isLibraryFile = !!libraryFileId && !archiveId;
+
+  // Multiple printer selection (used for all modes now)
+  const [selectedPrinters, setSelectedPrinters] = useState<number[]>(() => {
+    // Initialize with the queue item's printer if editing
+    if (mode === 'edit-queue-item' && queueItem?.printer_id) {
+      return [queueItem.printer_id];
     }
-    return null;
+    return [];
   });
 
-  // Multiple printer selection (for reprint and add-to-queue modes)
-  const [selectedPrinters, setSelectedPrinters] = useState<number[]>([]);
-
   const [selectedPlate, setSelectedPlate] = useState<number | null>(() => {
     if (mode === 'edit-queue-item' && queueItem) {
       return queueItem.plate_id;
@@ -109,20 +114,17 @@ export function PrintModal({
   });
 
   // Track initial values for clearing mappings on change (edit mode only)
-  const [initialPrinterId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.printer_id : null));
+  const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
   const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
 
   // Submission state for multi-printer
   const [isSubmitting, setIsSubmitting] = useState(false);
   const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 });
 
-  // Determine if we're in multi-printer mode
-  const isMultiPrinterMode = mode !== 'edit-queue-item';
-  const effectivePrinterCount = isMultiPrinterMode ? selectedPrinters.length : (selectedPrinter ? 1 : 0);
+  // Printer counts and effective printer for filament mapping
+  const effectivePrinterCount = selectedPrinters.length;
   // For filament mapping, use first selected printer (mapping applies to all)
-  const effectivePrinterId = isMultiPrinterMode
-    ? (selectedPrinters.length > 0 ? selectedPrinters[0] : null)
-    : selectedPrinter;
+  const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null;
 
   // Queries
   const { data: printers, isLoading: loadingPrinters } = useQuery({
@@ -130,17 +132,45 @@ export function PrintModal({
     queryFn: api.getPrinters,
   });
 
-  const { data: platesData } = useQuery({
+  // Fetch plates for archives
+  const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
     queryKey: ['archive-plates', archiveId],
-    queryFn: () => api.getArchivePlates(archiveId),
+    queryFn: () => api.getArchivePlates(archiveId!),
+    enabled: !!archiveId && !isLibraryFile,
+    retry: false,
+  });
+
+  // Fetch plates for library files
+  const { data: libraryPlatesData } = useQuery({
+    queryKey: ['library-file-plates', libraryFileId],
+    queryFn: () => api.getLibraryFilePlates(libraryFileId!),
+    enabled: isLibraryFile && !!libraryFileId,
   });
 
-  const { data: filamentReqs } = useQuery({
+  // Combine plates data from either source
+  const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
+
+  // Fetch filament requirements for archives
+  const { data: archiveFilamentReqs, isError: archiveFilamentReqsError } = useQuery({
     queryKey: ['archive-filaments', archiveId, selectedPlate],
-    queryFn: () => api.getArchiveFilamentRequirements(archiveId, selectedPlate ?? undefined),
-    enabled: selectedPlate !== null || !platesData?.is_multi_plate,
+    queryFn: () => api.getArchiveFilamentRequirements(archiveId!, selectedPlate ?? undefined),
+    enabled: !!archiveId && !isLibraryFile && (selectedPlate !== null || !platesData?.is_multi_plate),
+    retry: false,
   });
 
+  // Fetch filament requirements for library files (with plate support)
+  const { data: libraryFilamentReqs } = useQuery({
+    queryKey: ['library-file-filaments', libraryFileId, selectedPlate],
+    queryFn: () => api.getLibraryFileFilamentRequirements(libraryFileId!, selectedPlate ?? undefined),
+    enabled: isLibraryFile && !!libraryFileId && (selectedPlate !== null || !platesData?.is_multi_plate),
+  });
+
+  // Track if archive data couldn't be loaded (archive deleted or file missing)
+  const archiveDataMissing = !isLibraryFile && (archivePlatesError || archiveFilamentReqsError);
+
+  // Combine filament requirements from either source
+  const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
+
   // Only fetch printer status when single printer selected (for filament mapping)
   const { data: printerStatus } = useQuery({
     queryKey: ['printer-status', effectivePrinterId],
@@ -149,7 +179,7 @@ export function PrintModal({
   });
 
   // Get AMS mapping from hook (only when single printer selected)
-  const { amsMapping } = useFilamentMapping(filamentReqs, printerStatus, manualMappings);
+  const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings);
 
   // Auto-select first plate for single-plate files
   useEffect(() => {
@@ -158,8 +188,9 @@ export function PrintModal({
     }
   }, [platesData, selectedPlate]);
 
-  // Auto-select first printer when only one available (non-multi mode)
+  // Auto-select first printer when only one available
   useEffect(() => {
+    // Skip auto-select for edit mode (already initialized from queueItem)
     if (mode === 'edit-queue-item') return;
     const activePrinters = printers?.filter(p => p.is_active) || [];
     if (activePrinters.length === 1 && selectedPrinters.length === 0) {
@@ -170,13 +201,15 @@ export function PrintModal({
   // Clear manual mappings when printer or plate changes
   useEffect(() => {
     if (mode === 'edit-queue-item') {
-      if (selectedPrinter !== initialPrinterId || selectedPlate !== initialPlateId) {
+      // For edit mode, clear mappings if printer selection or plate changed from initial
+      const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort());
+      if (printersChanged || selectedPlate !== initialPlateId) {
         setManualMappings({});
       }
     } else {
       setManualMappings({});
     }
-  }, [mode, selectedPrinter, selectedPrinters, selectedPlate, initialPrinterId, initialPlateId]);
+  }, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
 
   // Close on Escape key
   useEffect(() => {
@@ -212,29 +245,7 @@ export function PrintModal({
   const handleSubmit = async (e?: React.FormEvent) => {
     e?.preventDefault();
 
-    if (mode === 'edit-queue-item') {
-      // Edit mode - single printer update
-      const data: PrintQueueItemUpdate = {
-        printer_id: selectedPrinter,
-        require_previous_success: scheduleOptions.requirePreviousSuccess,
-        auto_off_after: scheduleOptions.autoOffAfter,
-        manual_start: scheduleOptions.scheduleType === 'manual',
-        ams_mapping: amsMapping,
-        plate_id: selectedPlate,
-        ...printOptions,
-      };
-
-      if (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime) {
-        data.scheduled_time = new Date(scheduleOptions.scheduledTime).toISOString();
-      } else {
-        data.scheduled_time = null;
-      }
-
-      updateQueueMutation.mutate(data);
-      return;
-    }
-
-    // Multi-printer modes (reprint or add-to-queue)
+    // Validate printer selection
     if (selectedPrinters.length === 0) {
       showToast('Please select at least one printer', 'error');
       return;
@@ -249,37 +260,60 @@ export function PrintModal({
       errors: [],
     };
 
+    // Common queue data for add-to-queue and edit modes
+    const getQueueData = (printerId: number): PrintQueueItemCreate => ({
+      printer_id: printerId,
+      // Use library_file_id for library files, archive_id for archives
+      archive_id: isLibraryFile ? undefined : archiveId,
+      library_file_id: isLibraryFile ? libraryFileId : undefined,
+      require_previous_success: scheduleOptions.requirePreviousSuccess,
+      auto_off_after: scheduleOptions.autoOffAfter,
+      manual_start: scheduleOptions.scheduleType === 'manual',
+      ams_mapping: amsMapping,
+      plate_id: selectedPlate,
+      scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
+        ? new Date(scheduleOptions.scheduledTime).toISOString()
+        : undefined,
+      ...printOptions,
+    });
+
     for (let i = 0; i < selectedPrinters.length; i++) {
       const printerId = selectedPrinters[i];
       setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
 
       try {
-        // Use the same AMS mapping for all printers (configured via UI based on first printer)
-        // This assumes all printers have the same filament configuration
         if (mode === 'reprint') {
-          await api.reprintArchive(archiveId, printerId, {
-            plate_id: selectedPlate ?? undefined,
-            ams_mapping: amsMapping,
-            ...printOptions,
-          });
-        } else {
-          // add-to-queue mode
-          const data: PrintQueueItemCreate = {
+          // Reprint mode - start print immediately
+          if (isLibraryFile) {
+            await api.printLibraryFile(libraryFileId!, printerId, {
+              ams_mapping: amsMapping,
+              ...printOptions,
+            });
+          } else {
+            await api.reprintArchive(archiveId!, printerId, {
+              plate_id: selectedPlate ?? undefined,
+              ams_mapping: amsMapping,
+              ...printOptions,
+            });
+          }
+        } else if (mode === 'edit-queue-item' && i === 0) {
+          // Edit mode - update the original queue item for the first printer
+          const updateData: PrintQueueItemUpdate = {
             printer_id: printerId,
-            archive_id: archiveId,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             auto_off_after: scheduleOptions.autoOffAfter,
             manual_start: scheduleOptions.scheduleType === 'manual',
             ams_mapping: amsMapping,
             plate_id: selectedPlate,
+            scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
+              ? new Date(scheduleOptions.scheduledTime).toISOString()
+              : null,
             ...printOptions,
           };
-
-          if (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime) {
-            data.scheduled_time = new Date(scheduleOptions.scheduledTime).toISOString();
-          }
-
-          await addToQueueMutation.mutateAsync(data);
+          await updateQueueMutation.mutateAsync(updateData);
+        } else {
+          // Add-to-queue mode OR edit mode with additional printers
+          await addToQueueMutation.mutateAsync(getQueueData(printerId));
         }
         results.success++;
       } catch (error) {
@@ -293,9 +327,9 @@ export function PrintModal({
 
     // Show result toast
     if (results.failed === 0) {
-      const action = mode === 'reprint' ? 'sent to' : 'queued for';
+      const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
       if (results.success === 1) {
-        showToast(`Print ${action} printer`);
+        showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
       } else {
         showToast(`Print ${action} ${results.success} printers`);
       }
@@ -315,27 +349,22 @@ export function PrintModal({
   const canSubmit = useMemo(() => {
     if (isPending) return false;
 
-    // For edit mode, printer can be null (unassigned)
-    if (mode === 'edit-queue-item') {
-      return (printers?.length ?? 0) > 0;
-    }
-
-    // For reprint and add-to-queue, need at least one selected printer
+    // Need at least one selected printer
     if (selectedPrinters.length === 0) return false;
 
-    // For multi-plate files, need a selected plate
-    if (isMultiPlate && !selectedPlate) return false;
+    // For multi-plate archive files, need a selected plate (library files skip this)
+    if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
 
     return true;
-  }, [mode, selectedPrinters.length, isMultiPlate, selectedPlate, isPending, printers]);
+  }, [selectedPrinters.length, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
 
   // Modal title and action button text based on mode
   const getModalConfig = () => {
-    const printerCount = isMultiPrinterMode ? selectedPrinters.length : 1;
+    const printerCount = selectedPrinters.length;
 
     if (mode === 'reprint') {
       return {
-        title: 'Re-print',
+        title: isLibraryFile ? 'Print' : 'Re-print',
         icon: Printer,
         submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
         submitIcon: Printer,
@@ -355,12 +384,15 @@ export function PrintModal({
           : 'Adding...',
       };
     }
+    // edit-queue-item mode
     return {
       title: 'Edit Queue Item',
       icon: Pencil,
-      submitText: 'Save Changes',
+      submitText: 'Save',
       submitIcon: Pencil,
-      loadingText: 'Saving...',
+      loadingText: submitProgress.total > 1
+        ? `Saving ${submitProgress.current}/${submitProgress.total}...`
+        : 'Saving...',
     };
   };
 
@@ -368,8 +400,13 @@ export function PrintModal({
   const TitleIcon = modalConfig.icon;
   const SubmitIcon = modalConfig.submitIcon;
 
-  // Show filament mapping only when single printer selected
-  const showFilamentMapping = effectivePrinterId && (isMultiPlate ? selectedPlate !== null : true);
+  // Show filament mapping when:
+  // - Single printer selected
+  // - For archives: plate is selected (for multi-plate) or not required (single-plate)
+  // - For library files: always show (no plate selection)
+  const showFilamentMapping = effectivePrinterId && (
+    isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
+  );
 
   return (
     <div
@@ -401,8 +438,7 @@ export function PrintModal({
             <p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
               {mode === 'reprint' ? (
                 <>
-                  Send <span className="text-white">{archiveName}</span> to{' '}
-                  {isMultiPrinterMode ? 'printer(s)' : 'a printer'}
+                  Send <span className="text-white">{archiveName}</span> to printer(s)
                 </>
               ) : (
                 <>
@@ -415,17 +451,15 @@ export function PrintModal({
             {/* Printer selection */}
             <PrinterSelector
               printers={printers || []}
-              selectedPrinterId={selectedPrinter}
               selectedPrinterIds={selectedPrinters}
-              onSelect={setSelectedPrinter}
               onMultiSelect={setSelectedPrinters}
               isLoading={loadingPrinters}
-              allowUnassigned={mode === 'edit-queue-item'}
-              allowMultiple={isMultiPrinterMode}
+              allowMultiple={true}
+              showInactive={mode === 'edit-queue-item'}
             />
 
             {/* Multi-printer filament mapping note */}
-            {isMultiPrinterMode && selectedPrinters.length > 1 && (
+            {selectedPrinters.length > 1 && (
               <div className="flex items-start gap-2 p-3 mb-2 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm">
                 <AlertCircle className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" />
                 <p className="text-blue-400">
@@ -442,13 +476,21 @@ export function PrintModal({
               onSelect={setSelectedPlate}
             />
 
-            {/* Filament mapping - show when single printer selected and plate ready */}
-            {showFilamentMapping && (
+            {/* Warning when archive data couldn't be loaded */}
+            {archiveDataMissing && (
+              <div className="flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm">
+                <AlertCircle className="w-4 h-4 text-orange-400 mt-0.5 flex-shrink-0" />
+                <p className="text-orange-400">
+                  Archive data unavailable. The source file may have been deleted. Filament mapping is disabled.
+                </p>
+              </div>
+            )}
+
+            {/* Filament mapping - show when printer selected and filament requirements available */}
+            {showFilamentMapping && !archiveDataMissing && (
               <FilamentMapping
                 printerId={effectivePrinterId!}
-                archiveId={archiveId}
-                selectedPlate={selectedPlate}
-                isMultiPlate={isMultiPlate}
+                filamentReqs={effectiveFilamentReqs}
                 manualMappings={manualMappings}
                 onManualMappingChange={setManualMappings}
               />

+ 28 - 11
frontend/src/components/PrintModal/types.ts

@@ -10,13 +10,19 @@ export type PrintModalMode = 'reprint' | 'add-to-queue' | 'edit-queue-item';
 
 /**
  * Props for the unified PrintModal component.
+ *
+ * Either archiveId or libraryFileId must be provided.
+ * - archiveId: For reprinting/queueing archives
+ * - libraryFileId: For printing library files directly
  */
 export interface PrintModalProps {
   /** Modal operation mode */
   mode: PrintModalMode;
-  /** Archive ID to print */
-  archiveId: number;
-  /** Archive display name */
+  /** Archive ID to print (mutually exclusive with libraryFileId) */
+  archiveId?: number;
+  /** Library file ID to print (mutually exclusive with archiveId) */
+  libraryFileId?: number;
+  /** Display name for the print */
   archiveName: string;
   /** Existing queue item (only for edit-queue-item mode) */
   queueItem?: PrintQueueItem;
@@ -103,13 +109,12 @@ export interface PlatesResponse {
  */
 export interface PrinterSelectorProps {
   printers: Printer[];
-  selectedPrinterId: number | null;
-  selectedPrinterIds?: number[];
-  onSelect: (printerId: number | null) => void;
-  onMultiSelect?: (printerIds: number[]) => void;
+  selectedPrinterIds: number[];
+  onMultiSelect: (printerIds: number[]) => void;
   isLoading?: boolean;
-  allowUnassigned?: boolean;
   allowMultiple?: boolean;
+  /** Show inactive printers (for edit mode where original assignment may be inactive) */
+  showInactive?: boolean;
 }
 
 /**
@@ -122,14 +127,26 @@ export interface PlateSelectorProps {
   onSelect: (plateIndex: number) => void;
 }
 
+/**
+ * Filament requirement data structure.
+ */
+export interface FilamentReqsData {
+  filaments: Array<{
+    slot_id: number;
+    type: string;
+    color: string;
+    used_grams: number;
+    used_meters: number;
+  }>;
+}
+
 /**
  * Props for the FilamentMapping component.
  */
 export interface FilamentMappingProps {
   printerId: number;
-  archiveId: number;
-  selectedPlate: number | null;
-  isMultiPlate: boolean;
+  /** Pre-fetched filament requirements data */
+  filamentReqs: FilamentReqsData | undefined;
   manualMappings: Record<number, number>;
   onManualMappingChange: (mappings: Record<number, number>) => void;
 }

+ 47 - 11
frontend/src/pages/FileManagerPage.tsx

@@ -46,6 +46,7 @@ import type {
 } from '../api/client';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
+import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
@@ -695,9 +696,10 @@ interface FileCardProps {
   onDelete: (id: number) => void;
   onDownload: (id: number) => void;
   onAddToQueue?: (id: number) => void;
+  onPrint?: (file: LibraryFileListItem) => void;
 }
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue }: FileCardProps) {
+function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
@@ -771,12 +773,21 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
           <>
             <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
             <div className="absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]">
-              {onAddToQueue && isSlicedFilename(file.filename) && (
+              {onPrint && isSlicedFilename(file.filename) && (
                 <button
                   className="w-full px-3 py-1.5 text-left text-sm text-bambu-green hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
+                  onClick={() => { onPrint(file); setShowActions(false); }}
                 >
                   <Printer className="w-3.5 h-3.5" />
+                  Print
+                </button>
+              )}
+              {onAddToQueue && isSlicedFilename(file.filename) && (
+                <button
+                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
+                  onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
+                >
+                  <Clock className="w-3.5 h-3.5" />
                   Add to Queue
                 </button>
               )}
@@ -828,6 +839,7 @@ export function FileManagerPage() {
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
+  const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
@@ -1416,6 +1428,7 @@ export function FileManagerPage() {
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDownload={handleDownload}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
+                    onPrint={setPrintFile}
                   />
                 ))}
               </div>
@@ -1506,14 +1519,23 @@ export function FileManagerPage() {
                     {/* Actions */}
                     <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
                       {isSlicedFilename(file.filename) && (
-                        <button
-                          onClick={() => addToQueueMutation.mutate([file.id])}
-                          className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
-                          title="Add to Queue"
-                          disabled={addToQueueMutation.isPending}
-                        >
-                          <Printer className="w-4 h-4" />
-                        </button>
+                        <>
+                          <button
+                            onClick={() => setPrintFile(file)}
+                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
+                            title="Print"
+                          >
+                            <Printer className="w-4 h-4" />
+                          </button>
+                          <button
+                            onClick={() => addToQueueMutation.mutate([file.id])}
+                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+                            title="Add to Queue"
+                            disabled={addToQueueMutation.isPending}
+                          >
+                            <Clock className="w-4 h-4" />
+                          </button>
+                        </>
                       )}
                       <button
                         onClick={() => handleDownload(file.id)}
@@ -1598,6 +1620,20 @@ export function FileManagerPage() {
           onCancel={() => setDeleteConfirm(null)}
         />
       )}
+
+      {printFile && (
+        <PrintModal
+          mode="reprint"
+          libraryFileId={printFile.id}
+          archiveName={printFile.print_name || printFile.filename}
+          onClose={() => setPrintFile(null)}
+          onSuccess={() => {
+            setPrintFile(null);
+            queryClient.invalidateQueries({ queryKey: ['library-files'] });
+            queryClient.invalidateQueries({ queryKey: ['archives'] });
+          }}
+        />
+      )}
     </div>
   );
 }

+ 40 - 18
frontend/src/pages/QueuePage.tsx

@@ -167,7 +167,13 @@ function SortableQueueItem({
         <div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
           {item.archive_thumbnail ? (
             <img
-              src={api.getArchiveThumbnail(item.archive_id)}
+              src={api.getArchiveThumbnail(item.archive_id!)}
+              alt=""
+              className="w-full h-full object-cover"
+            />
+          ) : item.library_file_thumbnail ? (
+            <img
+              src={api.getLibraryFileThumbnailUrl(item.library_file_id!)}
               alt=""
               className="w-full h-full object-cover"
             />
@@ -182,15 +188,25 @@ function SortableQueueItem({
         <div className="flex-1 min-w-0">
           <div className="flex items-center gap-2 mb-1">
             <p className="text-white font-medium truncate">
-              {item.archive_name || `Archive #${item.archive_id}`}
+              {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
             </p>
-            <Link
-              to={`/archives?highlight=${item.archive_id}`}
-              className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
-              title="View archive"
-            >
-              <ExternalLink className="w-3.5 h-3.5" />
-            </Link>
+            {item.archive_id ? (
+              <Link
+                to={`/archives?highlight=${item.archive_id}`}
+                className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
+                title="View archive"
+              >
+                <ExternalLink className="w-3.5 h-3.5" />
+              </Link>
+            ) : item.library_file_id ? (
+              <Link
+                to={`/library?highlight=${item.library_file_id}`}
+                className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
+                title="View in File Manager"
+              >
+                <ExternalLink className="w-3.5 h-3.5" />
+              </Link>
+            ) : null}
           </div>
 
           <div className="flex items-center gap-3 text-sm text-bambu-gray">
@@ -472,7 +488,9 @@ export function QueuePage() {
     return [...items].sort((a, b) => {
       let cmp: number;
       if (pendingSortBy === 'name') {
-        cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
+        const aName = a.archive_name || a.library_file_name || '';
+        const bName = b.archive_name || b.library_file_name || '';
+        cmp = aName.localeCompare(bName);
       } else if (pendingSortBy === 'printer') {
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
       } else if (pendingSortBy === 'time') {
@@ -490,7 +508,9 @@ export function QueuePage() {
     return [...items].sort((a, b) => {
       let cmp: number;
       if (historySortBy === 'name') {
-        cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
+        const aName = a.archive_name || a.library_file_name || '';
+        const bName = b.archive_name || b.library_file_name || '';
+        cmp = aName.localeCompare(bName);
       } else if (historySortBy === 'printer') {
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
       } else {
@@ -803,8 +823,9 @@ export function QueuePage() {
       {editItem && (
         <PrintModal
           mode="edit-queue-item"
-          archiveId={editItem.archive_id}
-          archiveName={editItem.archive_name || `Archive #${editItem.archive_id}`}
+          archiveId={editItem.archive_id ?? undefined}
+          libraryFileId={editItem.library_file_id ?? undefined}
+          archiveName={editItem.archive_name || editItem.library_file_name || `File #${editItem.archive_id || editItem.library_file_id}`}
           queueItem={editItem}
           onClose={() => setEditItem(null)}
         />
@@ -814,8 +835,9 @@ export function QueuePage() {
       {requeueItem && (
         <PrintModal
           mode="add-to-queue"
-          archiveId={requeueItem.archive_id}
-          archiveName={requeueItem.archive_name || `Archive #${requeueItem.archive_id}`}
+          archiveId={requeueItem.archive_id ?? undefined}
+          libraryFileId={requeueItem.library_file_id ?? undefined}
+          archiveName={requeueItem.archive_name || requeueItem.library_file_name || `File #${requeueItem.archive_id || requeueItem.library_file_id}`}
           onClose={() => setRequeueItem(null)}
         />
       )}
@@ -830,10 +852,10 @@ export function QueuePage() {
           }
           message={
             confirmAction.type === 'cancel'
-              ? `Are you sure you want to cancel "${confirmAction.item.archive_name || 'this print'}"?`
+              ? `Are you sure you want to cancel "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"?`
               : confirmAction.type === 'stop'
-              ? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || 'this print'}"? This will cancel the print job on the printer.`
-              : `Are you sure you want to remove "${confirmAction.item.archive_name || 'this item'}" from the queue history?`
+              ? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"? This will cancel the print job on the printer.`
+              : `Are you sure you want to remove "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this item'}" from the queue history?`
           }
           confirmText={
             confirmAction.type === 'cancel' ? 'Cancel Print' :

+ 2 - 2
scripts/mqtt_sniffer.py

@@ -52,12 +52,12 @@ def on_message(client, userdata, msg):
 
         # Always log calibration messages with full detail
         if is_cali_msg:
-            print(f"\n{'='*80}")
+            print(f"\n{'=' * 80}")
             print(f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] *** CALIBRATION COMMAND: {command} ***")
             print(f"Topic: {msg.topic}")
             print("Full payload:")
             print(json.dumps(payload, indent=2))
-            print(f"{'='*80}\n")
+            print(f"{'=' * 80}\n")
         else:
             # For other messages, just show a brief summary
             if "print" in payload:

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


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


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


+ 2 - 2
static/index.html

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

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