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

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 месяцев назад
Родитель
Сommit
02f6de4adc

+ 15 - 1
CHANGELOG.md

@@ -2,7 +2,7 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
-## [0.1.6b11] - 2026-01-20
+## [Unreleased]
 
 
 ### New Features
 ### New Features
 - **Unified Print Modal** - Consolidated three separate modals into one unified component:
 - **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:
 - **Enhanced Add-to-Queue** - Now includes plate selection and print options:
   - Configure all print settings upfront instead of editing afterward
   - Configure all print settings upfront instead of editing afterward
   - Filament mapping with manual override capability
   - 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
 ### Fixed
 - **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
 - **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
   - Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
   - Folder navigation now works correctly without resetting
   - 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
 ## [0.1.6b10] - 2026-01-20
 
 

+ 8 - 0
README.md

@@ -78,6 +78,14 @@
 - Auto power-on before print
 - Auto power-on before print
 - Auto power-off after cooldown
 - 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
 ### 📁 Projects
 - Group related prints (e.g., "Voron Build")
 - Group related prints (e.g., "Voron Build")
 - Track plates (print jobs) and parts separately
 - 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,
     FileDuplicate,
     FileListResponse,
     FileListResponse,
     FileMoveRequest,
     FileMoveRequest,
+    FilePrintRequest,
     FileResponse as FileResponseSchema,
     FileResponse as FileResponseSchema,
     FileUpdate,
     FileUpdate,
     FileUploadResponse,
     FileUploadResponse,
@@ -755,10 +756,7 @@ async def add_files_to_queue(
     """Add library files to the print queue.
     """Add library files to the print queue.
 
 
     Only sliced files (.gcode or .gcode.3mf) can be added to the 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] = []
     added: list[AddToQueueResult] = []
     errors: list[AddToQueueError] = []
     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)))
     pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
     max_position = pos_result.scalar() or 0
     max_position = pos_result.scalar() or 0
 
 
-    archive_service = ArchiveService(db)
-
     for file_id in request.file_ids:
     for file_id in request.file_ids:
         lib_file = files.get(file_id)
         lib_file = files.get(file_id)
 
 
@@ -792,7 +788,7 @@ async def add_files_to_queue(
             continue
             continue
 
 
         try:
         try:
-            # Get the full file path
+            # Verify file exists on disk
             file_path = Path(app_settings.base_dir) / lib_file.file_path
             file_path = Path(app_settings.base_dir) / lib_file.file_path
 
 
             if not file_path.exists():
             if not file_path.exists():
@@ -801,23 +797,11 @@ async def add_files_to_queue(
                 )
                 )
                 continue
                 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
             max_position += 1
             queue_item = PrintQueueItem(
             queue_item = PrintQueueItem(
                 printer_id=None,  # Unassigned
                 printer_id=None,  # Unassigned
-                archive_id=archive.id,
+                library_file_id=file_id,
                 position=max_position,
                 position=max_position,
                 status="pending",
                 status="pending",
             )
             )
@@ -830,7 +814,6 @@ async def add_files_to_queue(
                     file_id=file_id,
                     file_id=file_id,
                     filename=lib_file.filename,
                     filename=lib_file.filename,
                     queue_item_id=queue_item.id,
                     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)
     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 ============
 # ============ 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.core.database import get_db
 from backend.app.models.archive import PrintArchive
 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.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.schemas.print_queue import (
 from backend.app.schemas.print_queue import (
@@ -26,7 +27,7 @@ router = APIRouter(prefix="/queue", tags=["queue"])
 
 
 
 
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
 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
     # Parse ams_mapping from JSON string BEFORE model_validate
     ams_mapping_parsed = None
     ams_mapping_parsed = None
     if item.ams_mapping:
     if item.ams_mapping:
@@ -40,6 +41,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "id": item.id,
         "id": item.id,
         "printer_id": item.printer_id,
         "printer_id": item.printer_id,
         "archive_id": item.archive_id,
         "archive_id": item.archive_id,
+        "library_file_id": item.library_file_id,
         "position": item.position,
         "position": item.position,
         "scheduled_time": item.scheduled_time,
         "scheduled_time": item.scheduled_time,
         "require_previous_success": item.require_previous_success,
         "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_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
         response.archive_thumbnail = item.archive.thumbnail_path
         response.print_time_seconds = item.archive.print_time_seconds
         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:
     if item.printer:
         response.printer_name = item.printer.name
         response.printer_name = item.printer.name
     return response
     return response
@@ -78,7 +90,11 @@ async def list_queue(
     """List all queue items, optionally filtered by printer or status."""
     """List all queue items, optionally filtered by printer or status."""
     query = (
     query = (
         select(PrintQueueItem)
         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)
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
     )
 
 
@@ -102,16 +118,27 @@ async def add_to_queue(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Add an item to the print queue."""
     """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)
     # Validate printer exists (if assigned)
     if data.printer_id is not None:
     if data.printer_id is not None:
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             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)
     # Get next position for this printer (or for unassigned items)
     if data.printer_id is not None:
     if data.printer_id is not None:
@@ -132,6 +159,7 @@ async def add_to_queue(
     item = PrintQueueItem(
     item = PrintQueueItem(
         printer_id=data.printer_id,
         printer_id=data.printer_id,
         archive_id=data.archive_id,
         archive_id=data.archive_id,
+        library_file_id=data.library_file_id,
         scheduled_time=data.scheduled_time,
         scheduled_time=data.scheduled_time,
         require_previous_success=data.require_previous_success,
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
         auto_off_after=data.auto_off_after,
@@ -152,9 +180,10 @@ async def add_to_queue(
     await db.refresh(item)
     await db.refresh(item)
 
 
     # Load relationships for response
     # 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
     # MQTT relay - publish queue job added
     try:
     try:
@@ -177,7 +206,11 @@ async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific queue item."""
     """Get a specific queue item."""
     result = await db.execute(
     result = await db.execute(
         select(PrintQueueItem)
         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)
         .where(PrintQueueItem.id == item_id)
     )
     )
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
@@ -217,7 +250,7 @@ async def update_queue_item(
         setattr(item, field, value)
         setattr(item, field, value)
 
 
     await db.commit()
     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}")
     logger.info(f"Updated queue item {item_id}")
     return _enrich_response(item)
     return _enrich_response(item)
@@ -372,7 +405,7 @@ async def start_queue_item(
     # Clear manual_start flag so scheduler picks it up
     # Clear manual_start flag so scheduler picks it up
     item.manual_start = False
     item.manual_start = False
     await db.commit()
     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)")
     logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
     return _enrich_response(item)
     return _enrich_response(item)

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

@@ -579,6 +579,68 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         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():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """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
     # Links
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
     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)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
 
 
     # Scheduling
     # Scheduling
@@ -57,10 +61,12 @@ class PrintQueueItem(Base):
 
 
     # Relationships
     # Relationships
     printer: Mapped["Printer"] = relationship()
     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")
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
 
 
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 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.printer import Printer  # noqa: E402
 from backend.app.models.project import Project  # 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):
 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)
     # Print options (same as archive reprint)
     plate_id: int | None = None
     plate_id: int | None = None
@@ -218,7 +219,6 @@ class AddToQueueResult(BaseModel):
     file_id: int
     file_id: int
     filename: str
     filename: str
     queue_item_id: int
     queue_item_id: int
-    archive_id: int
 
 
 
 
 class AddToQueueError(BaseModel):
 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):
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
     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)
     scheduled_time: datetime | None = None  # None = ASAP (next when idle)
     require_previous_success: bool = False
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
     auto_off_after: bool = False  # Power off printer after print completes
@@ -57,7 +59,8 @@ class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemResponse(BaseModel):
 class PrintQueueItemResponse(BaseModel):
     id: int
     id: int
     printer_id: int | None  # None = unassigned
     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
     position: int
     scheduled_time: UTCDatetime
     scheduled_time: UTCDatetime
     require_previous_success: bool
     require_previous_success: bool
@@ -81,8 +84,10 @@ class PrintQueueItemResponse(BaseModel):
     # Nested info for UI (populated in route)
     # Nested info for UI (populated in route)
     archive_name: str | None = None
     archive_name: str | None = None
     archive_thumbnail: 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
     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:
     class Config:
         from_attributes = True
         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")
         response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
         assert response.status_code == 400
         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 {
 export interface PrintQueueItem {
   id: number;
   id: number;
   printer_id: number | null;  // null = unassigned
   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;
   position: number;
   scheduled_time: string | null;
   scheduled_time: string | null;
   require_previous_success: boolean;
   require_previous_success: boolean;
@@ -867,13 +869,17 @@ export interface PrintQueueItem {
   created_at: string;
   created_at: string;
   archive_name?: string | null;
   archive_name?: string | null;
   archive_thumbnail?: string | null;
   archive_thumbnail?: string | null;
+  library_file_name?: string | null;
+  library_file_thumbnail?: string | null;
   printer_name?: 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 {
 export interface PrintQueueItemCreate {
   printer_id?: number | null;  // null = unassigned
   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;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   require_previous_success?: boolean;
   auto_off_after?: boolean;
   auto_off_after?: boolean;
@@ -2594,6 +2600,61 @@ export const api = {
       method: 'POST',
       method: 'POST',
       body: JSON.stringify({ file_ids: fileIds }),
       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
 // AMS History types

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

@@ -12,9 +12,7 @@ import type { FilamentMappingProps } from './types';
  */
  */
 export function FilamentMapping({
 export function FilamentMapping({
   printerId,
   printerId,
-  archiveId,
-  selectedPlate,
-  isMultiPlate,
+  filamentReqs,
   manualMappings,
   manualMappings,
   onManualMappingChange,
   onManualMappingChange,
 }: FilamentMappingProps) {
 }: FilamentMappingProps) {
@@ -22,13 +20,6 @@ export function FilamentMapping({
   const [isRefreshing, setIsRefreshing] = useState(false);
   const [isRefreshing, setIsRefreshing] = useState(false);
   const [isExpanded, setIsExpanded] = 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
   // Fetch printer status
   const { data: printerStatus } = useQuery({
   const { data: printerStatus } = useQuery({
     queryKey: ['printer-status', printerId],
     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';
 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({
 export function PrinterSelector({
   printers,
   printers,
-  selectedPrinterId,
-  selectedPrinterIds = [],
-  onSelect,
+  selectedPrinterIds,
   onMultiSelect,
   onMultiSelect,
   isLoading = false,
   isLoading = false,
-  allowUnassigned = false,
   allowMultiple = false,
   allowMultiple = false,
+  showInactive = false,
 }: PrinterSelectorProps) {
 }: 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) {
   if (isLoading) {
     return (
     return (
@@ -26,53 +24,17 @@ export function PrinterSelector({
     );
     );
   }
   }
 
 
-  // Use dropdown mode for edit scenarios (allows unassigning printer)
-  if (allowUnassigned) {
+  if (displayPrinters.length === 0) {
     return (
     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>
       </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) => {
   const handlePrinterClick = (printerId: number) => {
-    if (allowMultiple && onMultiSelect) {
+    if (allowMultiple) {
       // Multi-select mode: toggle printer in selection
       // Multi-select mode: toggle printer in selection
       if (selectedPrinterIds.includes(printerId)) {
       if (selectedPrinterIds.includes(printerId)) {
         onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
         onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
@@ -80,36 +42,27 @@ export function PrinterSelector({
         onMultiSelect([...selectedPrinterIds, printerId]);
         onMultiSelect([...selectedPrinterIds, printerId]);
       }
       }
     } else {
     } else {
-      // Single-select mode
-      onSelect(printerId);
+      // Single-select mode: replace selection
+      onMultiSelect([printerId]);
     }
     }
   };
   };
 
 
   const handleSelectAll = () => {
   const handleSelectAll = () => {
-    if (onMultiSelect) {
-      onMultiSelect(activePrinters.map((p) => p.id));
-    }
+    onMultiSelect(displayPrinters.map((p) => p.id));
   };
   };
 
 
   const handleDeselectAll = () => {
   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 (
   return (
     <div className="space-y-2 mb-6">
     <div className="space-y-2 mb-6">
       {/* Multi-select header */}
       {/* 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">
         <div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
           <span>
           <span>
             {selectedCount === 0
             {selectedCount === 0
@@ -117,7 +70,7 @@ export function PrinterSelector({
               : `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
               : `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
           </span>
           </span>
           <div className="flex gap-2">
           <div className="flex gap-2">
-            {selectedCount < activePrinters.length && (
+            {selectedCount < displayPrinters.length && (
               <button
               <button
                 type="button"
                 type="button"
                 onClick={handleSelectAll}
                 onClick={handleSelectAll}
@@ -139,7 +92,7 @@ export function PrinterSelector({
         </div>
         </div>
       )}
       )}
 
 
-      {activePrinters.map((printer) => (
+      {displayPrinters.map((printer) => (
         <button
         <button
           key={printer.id}
           key={printer.id}
           type="button"
           type="button"
@@ -148,7 +101,7 @@ export function PrinterSelector({
             isSelected(printer.id)
             isSelected(printer.id)
               ? 'border-bambu-green bg-bambu-green/10'
               ? 'border-bambu-green bg-bambu-green/10'
               : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
               : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
-          }`}
+          } ${!printer.is_active ? 'opacity-60' : ''}`}
         >
         >
           <div
           <div
             className={`p-2 rounded-lg ${
             className={`p-2 rounded-lg ${
@@ -162,7 +115,10 @@ export function PrinterSelector({
             />
             />
           </div>
           </div>
           <div className="text-left flex-1">
           <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">
             <p className="text-xs text-bambu-gray">
               {printer.model || 'Unknown model'} • {printer.ip_address}
               {printer.model || 'Unknown model'} • {printer.ip_address}
             </p>
             </p>
@@ -181,6 +137,14 @@ export function PrinterSelector({
           )}
           )}
         </button>
         </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>
     </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:
  * 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({
 export function PrintModal({
   mode,
   mode,
   archiveId,
   archiveId,
+  libraryFileId,
   archiveName,
   archiveName,
   queueItem,
   queueItem,
   onClose,
   onClose,
@@ -38,17 +42,18 @@ export function PrintModal({
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   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>(() => {
   const [selectedPlate, setSelectedPlate] = useState<number | null>(() => {
     if (mode === 'edit-queue-item' && queueItem) {
     if (mode === 'edit-queue-item' && queueItem) {
       return queueItem.plate_id;
       return queueItem.plate_id;
@@ -109,20 +114,17 @@ export function PrintModal({
   });
   });
 
 
   // Track initial values for clearing mappings on change (edit mode only)
   // 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));
   const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
 
 
   // Submission state for multi-printer
   // Submission state for multi-printer
   const [isSubmitting, setIsSubmitting] = useState(false);
   const [isSubmitting, setIsSubmitting] = useState(false);
   const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 });
   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)
   // 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
   // Queries
   const { data: printers, isLoading: loadingPrinters } = useQuery({
   const { data: printers, isLoading: loadingPrinters } = useQuery({
@@ -130,17 +132,45 @@ export function PrintModal({
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,
   });
   });
 
 
-  const { data: platesData } = useQuery({
+  // Fetch plates for archives
+  const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
     queryKey: ['archive-plates', archiveId],
     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],
     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)
   // Only fetch printer status when single printer selected (for filament mapping)
   const { data: printerStatus } = useQuery({
   const { data: printerStatus } = useQuery({
     queryKey: ['printer-status', effectivePrinterId],
     queryKey: ['printer-status', effectivePrinterId],
@@ -149,7 +179,7 @@ export function PrintModal({
   });
   });
 
 
   // Get AMS mapping from hook (only when single printer selected)
   // 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
   // Auto-select first plate for single-plate files
   useEffect(() => {
   useEffect(() => {
@@ -158,8 +188,9 @@ export function PrintModal({
     }
     }
   }, [platesData, selectedPlate]);
   }, [platesData, selectedPlate]);
 
 
-  // Auto-select first printer when only one available (non-multi mode)
+  // Auto-select first printer when only one available
   useEffect(() => {
   useEffect(() => {
+    // Skip auto-select for edit mode (already initialized from queueItem)
     if (mode === 'edit-queue-item') return;
     if (mode === 'edit-queue-item') return;
     const activePrinters = printers?.filter(p => p.is_active) || [];
     const activePrinters = printers?.filter(p => p.is_active) || [];
     if (activePrinters.length === 1 && selectedPrinters.length === 0) {
     if (activePrinters.length === 1 && selectedPrinters.length === 0) {
@@ -170,13 +201,15 @@ export function PrintModal({
   // Clear manual mappings when printer or plate changes
   // Clear manual mappings when printer or plate changes
   useEffect(() => {
   useEffect(() => {
     if (mode === 'edit-queue-item') {
     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({});
         setManualMappings({});
       }
       }
     } else {
     } else {
       setManualMappings({});
       setManualMappings({});
     }
     }
-  }, [mode, selectedPrinter, selectedPrinters, selectedPlate, initialPrinterId, initialPlateId]);
+  }, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
 
 
   // Close on Escape key
   // Close on Escape key
   useEffect(() => {
   useEffect(() => {
@@ -212,29 +245,7 @@ export function PrintModal({
   const handleSubmit = async (e?: React.FormEvent) => {
   const handleSubmit = async (e?: React.FormEvent) => {
     e?.preventDefault();
     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) {
     if (selectedPrinters.length === 0) {
       showToast('Please select at least one printer', 'error');
       showToast('Please select at least one printer', 'error');
       return;
       return;
@@ -249,37 +260,60 @@ export function PrintModal({
       errors: [],
       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++) {
     for (let i = 0; i < selectedPrinters.length; i++) {
       const printerId = selectedPrinters[i];
       const printerId = selectedPrinters[i];
       setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
       setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
 
 
       try {
       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') {
         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,
             printer_id: printerId,
-            archive_id: archiveId,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             auto_off_after: scheduleOptions.autoOffAfter,
             auto_off_after: scheduleOptions.autoOffAfter,
             manual_start: scheduleOptions.scheduleType === 'manual',
             manual_start: scheduleOptions.scheduleType === 'manual',
             ams_mapping: amsMapping,
             ams_mapping: amsMapping,
             plate_id: selectedPlate,
             plate_id: selectedPlate,
+            scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
+              ? new Date(scheduleOptions.scheduledTime).toISOString()
+              : null,
             ...printOptions,
             ...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++;
         results.success++;
       } catch (error) {
       } catch (error) {
@@ -293,9 +327,9 @@ export function PrintModal({
 
 
     // Show result toast
     // Show result toast
     if (results.failed === 0) {
     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) {
       if (results.success === 1) {
-        showToast(`Print ${action} printer`);
+        showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
       } else {
       } else {
         showToast(`Print ${action} ${results.success} printers`);
         showToast(`Print ${action} ${results.success} printers`);
       }
       }
@@ -315,27 +349,22 @@ export function PrintModal({
   const canSubmit = useMemo(() => {
   const canSubmit = useMemo(() => {
     if (isPending) return false;
     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;
     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;
     return true;
-  }, [mode, selectedPrinters.length, isMultiPlate, selectedPlate, isPending, printers]);
+  }, [selectedPrinters.length, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
 
 
   // Modal title and action button text based on mode
   // Modal title and action button text based on mode
   const getModalConfig = () => {
   const getModalConfig = () => {
-    const printerCount = isMultiPrinterMode ? selectedPrinters.length : 1;
+    const printerCount = selectedPrinters.length;
 
 
     if (mode === 'reprint') {
     if (mode === 'reprint') {
       return {
       return {
-        title: 'Re-print',
+        title: isLibraryFile ? 'Print' : 'Re-print',
         icon: Printer,
         icon: Printer,
         submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
         submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
         submitIcon: Printer,
         submitIcon: Printer,
@@ -355,12 +384,15 @@ export function PrintModal({
           : 'Adding...',
           : 'Adding...',
       };
       };
     }
     }
+    // edit-queue-item mode
     return {
     return {
       title: 'Edit Queue Item',
       title: 'Edit Queue Item',
       icon: Pencil,
       icon: Pencil,
-      submitText: 'Save Changes',
+      submitText: 'Save',
       submitIcon: Pencil,
       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 TitleIcon = modalConfig.icon;
   const SubmitIcon = modalConfig.submitIcon;
   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 (
   return (
     <div
     <div
@@ -401,8 +438,7 @@ export function PrintModal({
             <p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
             <p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
               {mode === 'reprint' ? (
               {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 */}
             {/* Printer selection */}
             <PrinterSelector
             <PrinterSelector
               printers={printers || []}
               printers={printers || []}
-              selectedPrinterId={selectedPrinter}
               selectedPrinterIds={selectedPrinters}
               selectedPrinterIds={selectedPrinters}
-              onSelect={setSelectedPrinter}
               onMultiSelect={setSelectedPrinters}
               onMultiSelect={setSelectedPrinters}
               isLoading={loadingPrinters}
               isLoading={loadingPrinters}
-              allowUnassigned={mode === 'edit-queue-item'}
-              allowMultiple={isMultiPrinterMode}
+              allowMultiple={true}
+              showInactive={mode === 'edit-queue-item'}
             />
             />
 
 
             {/* Multi-printer filament mapping note */}
             {/* 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">
               <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" />
                 <AlertCircle className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" />
                 <p className="text-blue-400">
                 <p className="text-blue-400">
@@ -442,13 +476,21 @@ export function PrintModal({
               onSelect={setSelectedPlate}
               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
               <FilamentMapping
                 printerId={effectivePrinterId!}
                 printerId={effectivePrinterId!}
-                archiveId={archiveId}
-                selectedPlate={selectedPlate}
-                isMultiPlate={isMultiPlate}
+                filamentReqs={effectiveFilamentReqs}
                 manualMappings={manualMappings}
                 manualMappings={manualMappings}
                 onManualMappingChange={setManualMappings}
                 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.
  * 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 {
 export interface PrintModalProps {
   /** Modal operation mode */
   /** Modal operation mode */
   mode: PrintModalMode;
   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;
   archiveName: string;
   /** Existing queue item (only for edit-queue-item mode) */
   /** Existing queue item (only for edit-queue-item mode) */
   queueItem?: PrintQueueItem;
   queueItem?: PrintQueueItem;
@@ -103,13 +109,12 @@ export interface PlatesResponse {
  */
  */
 export interface PrinterSelectorProps {
 export interface PrinterSelectorProps {
   printers: Printer[];
   printers: Printer[];
-  selectedPrinterId: number | null;
-  selectedPrinterIds?: number[];
-  onSelect: (printerId: number | null) => void;
-  onMultiSelect?: (printerIds: number[]) => void;
+  selectedPrinterIds: number[];
+  onMultiSelect: (printerIds: number[]) => void;
   isLoading?: boolean;
   isLoading?: boolean;
-  allowUnassigned?: boolean;
   allowMultiple?: 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;
   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.
  * Props for the FilamentMapping component.
  */
  */
 export interface FilamentMappingProps {
 export interface FilamentMappingProps {
   printerId: number;
   printerId: number;
-  archiveId: number;
-  selectedPlate: number | null;
-  isMultiPlate: boolean;
+  /** Pre-fetched filament requirements data */
+  filamentReqs: FilamentReqsData | undefined;
   manualMappings: Record<number, number>;
   manualMappings: Record<number, number>;
   onManualMappingChange: (mappings: Record<number, number>) => void;
   onManualMappingChange: (mappings: Record<number, number>) => void;
 }
 }

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

@@ -46,6 +46,7 @@ import type {
 } from '../api/client';
 } from '../api/client';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
+import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
@@ -695,9 +696,10 @@ interface FileCardProps {
   onDelete: (id: number) => void;
   onDelete: (id: number) => void;
   onDownload: (id: number) => void;
   onDownload: (id: number) => void;
   onAddToQueue?: (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);
   const [showActions, setShowActions] = useState(false);
 
 
   return (
   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="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]">
             <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
                 <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"
                   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" />
                   <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
                   Add to Queue
                 </button>
                 </button>
               )}
               )}
@@ -828,6 +839,7 @@ export function FileManagerPage() {
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | 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'>(() => {
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
   });
@@ -1416,6 +1428,7 @@ export function FileManagerPage() {
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDownload={handleDownload}
                     onDownload={handleDownload}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
+                    onPrint={setPrintFile}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -1506,14 +1519,23 @@ export function FileManagerPage() {
                     {/* Actions */}
                     {/* Actions */}
                     <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
                     <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
                       {isSlicedFilename(file.filename) && (
                       {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
                       <button
                         onClick={() => handleDownload(file.id)}
                         onClick={() => handleDownload(file.id)}
@@ -1598,6 +1620,20 @@ export function FileManagerPage() {
           onCancel={() => setDeleteConfirm(null)}
           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>
     </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">
         <div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
           {item.archive_thumbnail ? (
           {item.archive_thumbnail ? (
             <img
             <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=""
               alt=""
               className="w-full h-full object-cover"
               className="w-full h-full object-cover"
             />
             />
@@ -182,15 +188,25 @@ function SortableQueueItem({
         <div className="flex-1 min-w-0">
         <div className="flex-1 min-w-0">
           <div className="flex items-center gap-2 mb-1">
           <div className="flex items-center gap-2 mb-1">
             <p className="text-white font-medium truncate">
             <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>
             </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>
 
 
           <div className="flex items-center gap-3 text-sm text-bambu-gray">
           <div className="flex items-center gap-3 text-sm text-bambu-gray">
@@ -472,7 +488,9 @@ export function QueuePage() {
     return [...items].sort((a, b) => {
     return [...items].sort((a, b) => {
       let cmp: number;
       let cmp: number;
       if (pendingSortBy === 'name') {
       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') {
       } else if (pendingSortBy === 'printer') {
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
       } else if (pendingSortBy === 'time') {
       } else if (pendingSortBy === 'time') {
@@ -490,7 +508,9 @@ export function QueuePage() {
     return [...items].sort((a, b) => {
     return [...items].sort((a, b) => {
       let cmp: number;
       let cmp: number;
       if (historySortBy === 'name') {
       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') {
       } else if (historySortBy === 'printer') {
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
       } else {
       } else {
@@ -803,8 +823,9 @@ export function QueuePage() {
       {editItem && (
       {editItem && (
         <PrintModal
         <PrintModal
           mode="edit-queue-item"
           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}
           queueItem={editItem}
           onClose={() => setEditItem(null)}
           onClose={() => setEditItem(null)}
         />
         />
@@ -814,8 +835,9 @@ export function QueuePage() {
       {requeueItem && (
       {requeueItem && (
         <PrintModal
         <PrintModal
           mode="add-to-queue"
           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)}
           onClose={() => setRequeueItem(null)}
         />
         />
       )}
       )}
@@ -830,10 +852,10 @@ export function QueuePage() {
           }
           }
           message={
           message={
             confirmAction.type === 'cancel'
             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'
               : 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={
           confirmText={
             confirmAction.type === 'cancel' ? 'Cancel Print' :
             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
         # Always log calibration messages with full detail
         if is_cali_msg:
         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"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] *** CALIBRATION COMMAND: {command} ***")
             print(f"Topic: {msg.topic}")
             print(f"Topic: {msg.topic}")
             print("Full payload:")
             print("Full payload:")
             print(json.dumps(payload, indent=2))
             print(json.dumps(payload, indent=2))
-            print(f"{'='*80}\n")
+            print(f"{'=' * 80}\n")
         else:
         else:
             # For other messages, just show a brief summary
             # For other messages, just show a brief summary
             if "print" in payload:
             if "print" in payload:

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


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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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