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

Merge branch '0.1.8b' into feature/updated_plate_view_v2

MartinNYHC 7 месяцев назад
Родитель
Сommit
2833270e29
45 измененных файлов с 2122 добавлено и 289 удалено
  1. 46 0
      .github/workflows/codeql.yml
  2. 42 0
      CHANGELOG.md
  3. 8 4
      backend/app/api/routes/archives.py
  4. 24 0
      backend/app/api/routes/library.py
  5. 2 4
      backend/app/api/routes/metrics.py
  6. 1 1
      backend/app/api/routes/printers.py
  7. 25 1
      backend/app/api/routes/settings.py
  8. 2 2
      backend/app/core/permissions.py
  9. 7 3
      backend/app/main.py
  10. 2 2
      backend/app/schemas/github_backup.py
  11. 7 0
      backend/app/schemas/library.py
  12. 2 2
      backend/app/schemas/notification.py
  13. 2 2
      backend/app/schemas/notification_template.py
  14. 2 2
      backend/app/services/firmware_update.py
  15. 119 0
      backend/app/services/network_utils.py
  16. 71 22
      backend/app/services/print_scheduler.py
  17. 116 19
      backend/app/services/virtual_printer/manager.py
  18. 245 20
      backend/app/services/virtual_printer/ssdp_server.py
  19. 3 2
      backend/app/services/virtual_printer/tcp_proxy.py
  20. 163 2
      backend/tests/unit/services/test_virtual_printer.py
  21. 202 0
      backend/tests/unit/test_scheduler_ams_mapping.py
  22. 25 2
      frontend/src/__tests__/api/client.test.ts
  23. 2 1
      frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
  24. 349 0
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  25. 144 57
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  26. 36 9
      frontend/src/api/client.ts
  27. 4 2
      frontend/src/components/FilamentHoverCard.tsx
  28. 2 2
      frontend/src/components/FilamentTrends.tsx
  29. 11 2
      frontend/src/components/PrintModal/index.tsx
  30. 58 4
      frontend/src/components/VirtualPrinterSettings.tsx
  31. 136 45
      frontend/src/hooks/useFilamentMapping.ts
  32. 19 7
      frontend/src/i18n/locales/de.ts
  33. 19 7
      frontend/src/i18n/locales/en.ts
  34. 31 0
      frontend/src/i18n/locales/ja.ts
  35. 2 2
      frontend/src/pages/ArchivesPage.tsx
  36. 142 54
      frontend/src/pages/FileManagerPage.tsx
  37. 47 3
      frontend/src/pages/QueuePage.tsx
  38. 2 2
      frontend/src/pages/StatsPage.tsx
  39. 0 0
      static/assets/index-B5vyvTs3.js
  40. 0 0
      static/assets/index-BtqA5ccK.css
  41. 0 0
      static/assets/index-DME4t7XG.css
  42. 0 0
      static/assets/index-SHZgHE2U.js
  43. 0 0
      static/assets/index-qXxId8sa.css
  44. 0 0
      static/assets/index-zuNzNq45.js
  45. 2 2
      static/index.html

+ 46 - 0
.github/workflows/codeql.yml

@@ -0,0 +1,46 @@
+name: CodeQL
+
+on:
+  push:
+    branches: ['**']
+  pull_request:
+    branches: ['**']
+  schedule:
+    # Run weekly on Sunday at 3:00 UTC
+    - cron: '0 3 * * 0'
+
+# Cancel in-progress runs for the same branch
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+permissions:
+  contents: read
+  security-events: write
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        language: ['javascript-typescript', 'python']
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Initialize CodeQL
+        uses: github/codeql-action/init@v4
+        with:
+          languages: ${{ matrix.language }}
+          # Use default queries plus security-extended
+          queries: security-extended
+
+      - name: Autobuild
+        uses: github/codeql-action/autobuild@v4
+
+      - name: Perform CodeQL Analysis
+        uses: github/codeql-action/analyze@v4
+        with:
+          category: '/language:${{ matrix.language }}'

+ 42 - 0
CHANGELOG.md

@@ -5,6 +5,48 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.1.8b] - Not released
 
+### Enhanced
+- **Virtual Printer Proxy Mode Improvements**:
+  - SSDP proxy for cross-network setups: select slicer network interface for automatic printer discovery via SSDP relay
+  - FTP proxy now listens on privileged port 990 (matching Bambu Studio expectations) instead of 9990
+  - For systemd: requires `AmbientCapabilities=CAP_NET_BIND_SERVICE` capability
+  - Automatic directory permission checking at startup with clear error messages for Docker/bare metal
+  - Updated translations for proxy mode steps in English, German, and Japanese
+
+### Fixed
+- **Authentication Required Error After Initial Setup** (Issue #257):
+  - Fixed "Authentication required" error when using printer controls after fresh install with auth enabled
+  - Token clearing on 401 responses is now more selective - only clears on invalid token messages
+  - Generic "Authentication required" errors (which may be timing issues) no longer clear the token
+  - Also fixed smart plug discovery scan endpoints missing auth headers
+- **Filament Hover Card Overlapping Navigation Bar** (Issue #259):
+  - Fixed filament info popup being partially covered by the navigation bar
+  - Hover card positioning now accounts for the fixed 56px header
+  - Cards near the top of the page now correctly flip to show below the slot
+- **Filament Statistics Incorrectly Multiplied by Quantity** (Issue #229):
+  - Fixed filament totals being inflated by incorrectly multiplying by quantity
+  - The `filament_used_grams` field already contains the total for the entire print job
+  - Removed incorrect `* quantity` multiplication from archive stats, Prometheus metrics, and FilamentTrends chart
+  - Example: A print with 26 objects using 126g was incorrectly shown as 3,276g
+- **Print Queue Status Does Not Match Printer Status** (Issue #249):
+  - Queue now shows "Paused" when the printer is paused instead of "Printing"
+  - Fetches real-time printer state for actively printing queue items
+  - Added translations for paused status in English, German, and Japanese
+- **Queue Scheduled Time Displayed in Wrong Timezone** (Issue #233):
+  - Fixed scheduled time being displayed in UTC instead of local timezone when editing queue items
+  - The datetime picker now correctly shows and saves times in the user's local timezone
+- **Mobile Layout Issues on Archives and Statistics Pages** (Issue #255):
+  - Fixed header buttons overflowing outside the screen on iPhone/mobile devices
+  - Headers now stack vertically on small screens with proper wrapping
+  - Applied consistent responsive pattern from PrintersPage
+- **AMS Auto-Matching Selects Wrong Slot** (Issue #245):
+  - Fixed AMS slot mapping when multiple trays have the same `tray_info_idx` (filament type identifier)
+  - `tray_info_idx` (e.g., "GFA00" for generic PLA) identifies filament TYPE, not unique spools
+  - When multiple trays match the same type, color is now used as a tiebreaker
+  - Previously used `find()` which always returned the first match regardless of color
+  - Fixed in both backend (print_scheduler.py) and frontend (useFilamentMapping.ts)
+  - Resolves wrong tray selection (e.g., A4 instead of B1) when multiple AMS units have same filament type
+
 ### Added
 - **Windows Portable Launcher** (contributed by nmori):
   - New `start_bambuddy.bat` for Windows users - double-click to run, no installation required

+ 8 - 4
backend/app/api/routes/archives.py

@@ -466,10 +466,8 @@ async def get_archive_stats(
             total_seconds += print_time_seconds
     total_time = total_seconds / 3600  # Convert to hours
 
-    # Multiply filament by quantity to account for multiple items printed
-    filament_result = await db.execute(
-        select(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)))
-    )
+    # Sum filament directly - filament_used_grams already contains the total for the print job
+    filament_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
     total_filament = filament_result.scalar() or 0
 
     cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
@@ -2604,6 +2602,8 @@ async def get_filament_requirements(
                                 used_g = filament_elem.get("used_g", "0")
                                 used_m = filament_elem.get("used_m", "0")
 
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                                 try:
                                     used_grams = float(used_g)
                                 except (ValueError, TypeError):
@@ -2617,6 +2617,7 @@ async def get_filament_requirements(
                                             "color": filament_color,
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
+                                            "tray_info_idx": tray_info_idx,
                                         }
                                     )
                             break
@@ -2630,6 +2631,8 @@ async def get_filament_requirements(
                         used_g = filament_elem.get("used_g", "0")
                         used_m = filament_elem.get("used_m", "0")
 
+                        tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                         # Only include filaments that are actually used
                         try:
                             used_grams = float(used_g)
@@ -2644,6 +2647,7 @@ async def get_filament_requirements(
                                     "color": filament_color,
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
+                                    "tray_info_idx": tray_info_idx,
                                 }
                             )
 

+ 24 - 0
backend/app/api/routes/library.py

@@ -664,10 +664,12 @@ async def list_files(
         print_name = None
         print_time = None
         filament_grams = None
+        sliced_for_model = None
         if f.file_metadata:
             print_name = f.file_metadata.get("print_name")
             print_time = f.file_metadata.get("print_time_seconds")
             filament_grams = f.file_metadata.get("filament_used_grams")
+            sliced_for_model = f.file_metadata.get("sliced_for_model")
 
         file_list.append(
             FileListResponse(
@@ -685,6 +687,7 @@ async def list_files(
                 print_name=print_name,
                 print_time_seconds=print_time,
                 filament_used_grams=filament_grams,
+                sliced_for_model=sliced_for_model,
             )
         )
 
@@ -1661,6 +1664,8 @@ async def get_library_file_filament_requirements(
                                 used_g = filament_elem.get("used_g", "0")
                                 used_m = filament_elem.get("used_m", "0")
 
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                                 try:
                                     used_grams = float(used_g)
                                 except (ValueError, TypeError):
@@ -1674,6 +1679,7 @@ async def get_library_file_filament_requirements(
                                             "color": filament_color,
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
+                                            "tray_info_idx": tray_info_idx,
                                         }
                                     )
                             break
@@ -1686,6 +1692,8 @@ async def get_library_file_filament_requirements(
                         used_g = filament_elem.get("used_g", "0")
                         used_m = filament_elem.get("used_m", "0")
 
+                        tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                         try:
                             used_grams = float(used_g)
                         except (ValueError, TypeError):
@@ -1699,6 +1707,7 @@ async def get_library_file_filament_requirements(
                                     "color": filament_color,
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
+                                    "tray_info_idx": tray_info_idx,
                                 }
                             )
 
@@ -1957,6 +1966,17 @@ async def get_file(
             )
         duplicate_count = len(duplicates)
 
+    # Extract key metadata fields
+    print_name = None
+    print_time = None
+    filament_grams = None
+    sliced_for_model = None
+    if file.file_metadata:
+        print_name = file.file_metadata.get("print_name")
+        print_time = file.file_metadata.get("print_time_seconds")
+        filament_grams = file.file_metadata.get("filament_used_grams")
+        sliced_for_model = file.file_metadata.get("sliced_for_model")
+
     return FileResponseSchema(
         id=file.id,
         folder_id=file.folder_id,
@@ -1979,6 +1999,10 @@ async def get_file(
         created_by_username=file.created_by.username if file.created_by else None,
         created_at=file.created_at,
         updated_at=file.updated_at,
+        print_name=print_name,
+        print_time_seconds=print_time,
+        filament_used_grams=filament_grams,
+        sliced_for_model=sliced_for_model,
     )
 
 

+ 2 - 4
backend/app/api/routes/metrics.py

@@ -362,13 +362,11 @@ async def get_metrics(
             )
             lines.append(f"bambuddy_printer_prints_total{labels} {count}")
 
-    # Total filament used (multiply by quantity to account for multiple items printed)
+    # Total filament used - filament_used_grams already contains the total for each print job
     lines.append("")
     lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
     lines.append("# TYPE bambuddy_filament_used_grams counter")
-    result = await db.execute(
-        select(func.coalesce(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)), 0))
-    )
+    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
     total_filament = result.scalar() or 0
     lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
 

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

@@ -543,9 +543,9 @@ _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
 async def get_printer_cover(
     printer_id: int,
     view: str | None = None,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
+    # Note: No auth required - this is an image asset loaded via <img src> which can't send auth headers
     """Get the cover image for the current print job.
 
     Args:

+ 25 - 1
backend/app/api/routes/settings.py

@@ -434,6 +434,17 @@ async def restore_backup(
             )
 
 
+@router.get("/network-interfaces")
+async def get_network_interfaces(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Get available network interfaces for SSDP proxy configuration."""
+    from backend.app.services.network_utils import get_network_interfaces
+
+    interfaces = get_network_interfaces()
+    return {"interfaces": interfaces}
+
+
 @router.get("/virtual-printer/models")
 async def get_virtual_printer_models(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
@@ -466,6 +477,7 @@ async def get_virtual_printer_settings(
     mode = await get_setting(db, "virtual_printer_mode")
     model = await get_setting(db, "virtual_printer_model")
     target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
+    remote_interface_ip = await get_setting(db, "virtual_printer_remote_interface_ip")
 
     return {
         "enabled": enabled == "true" if enabled else False,
@@ -473,6 +485,7 @@ async def get_virtual_printer_settings(
         "mode": mode or "immediate",
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
+        "remote_interface_ip": remote_interface_ip or "",
         "status": virtual_printer_manager.get_status(),
     }
 
@@ -484,10 +497,16 @@ async def update_virtual_printer_settings(
     mode: str = None,
     model: str = None,
     target_printer_id: int = None,
+    remote_interface_ip: str = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
-    """Update virtual printer settings and restart services if needed."""
+    """Update virtual printer settings and restart services if needed.
+
+    For proxy mode with SSDP proxy (dual-homed setup):
+    - remote_interface_ip: IP of interface on slicer's network (LAN B)
+    - Local interface is auto-detected based on target printer IP
+    """
     from sqlalchemy import select
 
     from backend.app.models.printer import Printer
@@ -504,6 +523,7 @@ async def update_virtual_printer_settings(
     current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
     current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
     current_target_id = int(current_target_id_str) if current_target_id_str else None
+    current_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
 
     # Apply updates
     new_enabled = enabled if enabled is not None else current_enabled
@@ -511,6 +531,7 @@ async def update_virtual_printer_settings(
     new_mode = mode if mode is not None else current_mode
     new_model = model if model is not None else current_model
     new_target_id = target_printer_id if target_printer_id is not None else current_target_id
+    new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
 
     # Validate mode
     # "review" is the new name for "queue" (pending review before archiving)
@@ -587,6 +608,8 @@ async def update_virtual_printer_settings(
         await set_setting(db, "virtual_printer_model", model)
     if target_printer_id is not None:
         await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
+    if remote_interface_ip is not None:
+        await set_setting(db, "virtual_printer_remote_interface_ip", remote_interface_ip)
     await db.commit()
     db.expire_all()
 
@@ -599,6 +622,7 @@ async def update_virtual_printer_settings(
             model=new_model,
             target_printer_ip=target_printer_ip,
             target_printer_serial=target_printer_serial,
+            remote_interface_ip=new_remote_iface,
         )
     except ValueError as e:
         logger.warning(f"Virtual printer configuration validation error: {e}")

+ 2 - 2
backend/app/core/permissions.py

@@ -4,10 +4,10 @@ This module defines all permissions using a string enum with `resource:action` n
 Permissions are additive across groups - a user has all permissions from all their groups.
 """
 
-from enum import Enum
+from enum import StrEnum
 
 
-class Permission(str, Enum):
+class Permission(StrEnum):
     """All available permissions in the system.
 
     Permissions follow the pattern: resource:action

+ 7 - 3
backend/app/main.py

@@ -727,7 +727,11 @@ async def on_print_start(printer_id: int, data: dict):
         printer = result.scalar_one_or_none()
 
         # Plate detection check - pause if objects detected on build plate
+        logger.info(
+            f"[PLATE CHECK] printer_id={printer_id}, plate_detection_enabled={printer.plate_detection_enabled if printer else 'NO PRINTER'}"
+        )
         if printer and printer.plate_detection_enabled:
+            logger.info(f"[PLATE CHECK] ENTERING plate detection code for printer {printer_id}")
             try:
                 from backend.app.services.plate_detection import check_plate_empty
 
@@ -2423,8 +2427,6 @@ async def lifespan(app: FastAPI):
 
         # Restore MQTT smart plug subscriptions
         if mqtt_settings.get("mqtt_enabled"):
-            from sqlalchemy import select
-
             from backend.app.models.smart_plug import SmartPlug
 
             result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
@@ -2501,6 +2503,7 @@ async def lifespan(app: FastAPI):
             vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
             vp_model = await get_setting(db, "virtual_printer_model") or ""
             vp_target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
+            vp_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
 
             # Look up printer IP and serial if in proxy mode
             vp_target_ip = ""
@@ -2526,6 +2529,7 @@ async def lifespan(app: FastAPI):
                         model=vp_model,
                         target_printer_ip=vp_target_ip,
                         target_printer_serial=vp_target_serial,
+                        remote_interface_ip=vp_remote_iface,
                     )
                     if vp_mode == "proxy":
                         logging.info(f"Virtual printer proxy started (target={vp_target_ip})")
@@ -2662,8 +2666,8 @@ async def auth_middleware(request, call_next):
         )
 
     # Validate JWT token
+    import jwt
     try:
-        import jwt
 
         from backend.app.core.auth import ALGORITHM, SECRET_KEY
 

+ 2 - 2
backend/app/schemas/github_backup.py

@@ -2,12 +2,12 @@
 
 import re
 from datetime import datetime
-from enum import Enum
+from enum import StrEnum
 
 from pydantic import BaseModel, Field, field_validator
 
 
-class ScheduleType(str, Enum):
+class ScheduleType(StrEnum):
     """Backup schedule types."""
 
     HOURLY = "hourly"

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

@@ -130,6 +130,12 @@ class FileResponse(BaseModel):
     created_at: datetime
     updated_at: datetime
 
+    # Metadata fields
+    print_name: str | None = None
+    print_time_seconds: int | None = None
+    filament_used_grams: float | None = None
+    sliced_for_model: str | None = None
+
     class Config:
         from_attributes = True
 
@@ -154,6 +160,7 @@ class FileListResponse(BaseModel):
     print_name: str | None = None
     print_time_seconds: int | None = None
     filament_used_grams: float | None = None
+    sliced_for_model: str | None = None
 
     class Config:
         from_attributes = True

+ 2 - 2
backend/app/schemas/notification.py

@@ -1,13 +1,13 @@
 """Pydantic schemas for notification providers."""
 
 from datetime import datetime
-from enum import Enum
+from enum import StrEnum
 from typing import Any
 
 from pydantic import BaseModel, Field, field_validator
 
 
-class ProviderType(str, Enum):
+class ProviderType(StrEnum):
     """Supported notification provider types."""
 
     CALLMEBOT = "callmebot"

+ 2 - 2
backend/app/schemas/notification_template.py

@@ -1,12 +1,12 @@
 """Pydantic schemas for notification templates."""
 
 from datetime import datetime
-from enum import Enum
+from enum import StrEnum
 
 from pydantic import BaseModel, Field
 
 
-class EventType(str, Enum):
+class EventType(StrEnum):
     """Supported notification event types."""
 
     PRINT_START = "print_start"

+ 2 - 2
backend/app/services/firmware_update.py

@@ -11,7 +11,7 @@ Orchestrates firmware updates for Bambu Lab printers:
 import asyncio
 import logging
 from dataclasses import dataclass
-from enum import Enum
+from enum import StrEnum
 
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,7 +30,7 @@ from backend.app.services.printer_manager import printer_manager
 logger = logging.getLogger(__name__)
 
 
-class FirmwareUploadStatus(str, Enum):
+class FirmwareUploadStatus(StrEnum):
     """Status of a firmware upload operation."""
 
     IDLE = "idle"

+ 119 - 0
backend/app/services/network_utils.py

@@ -0,0 +1,119 @@
+"""Network utility functions for interface detection."""
+
+import ipaddress
+import logging
+import socket
+import struct
+
+logger = logging.getLogger(__name__)
+
+# Interfaces to exclude from selection
+EXCLUDED_INTERFACE_PREFIXES = ("lo", "docker", "br-", "veth", "virbr")
+
+
+def get_network_interfaces() -> list[dict]:
+    """Get all network interfaces with their IPs and subnets.
+
+    Returns:
+        List of dicts with name, ip, netmask, subnet, broadcast
+    """
+    interfaces = []
+
+    try:
+        import fcntl
+
+        for iface in socket.if_nameindex():
+            name = iface[1]
+
+            # Skip excluded interfaces
+            if any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES):
+                continue
+
+            try:
+                s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+
+                # Get IP address
+                ip_bytes = fcntl.ioctl(
+                    s.fileno(),
+                    0x8915,  # SIOCGIFADDR
+                    struct.pack("256s", name[:15].encode()),
+                )[20:24]
+                ip = socket.inet_ntoa(ip_bytes)
+
+                # Get netmask
+                netmask_bytes = fcntl.ioctl(
+                    s.fileno(),
+                    0x891B,  # SIOCGIFNETMASK
+                    struct.pack("256s", name[:15].encode()),
+                )[20:24]
+                netmask = socket.inet_ntoa(netmask_bytes)
+
+                # Calculate subnet
+                network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
+
+                interfaces.append(
+                    {
+                        "name": name,
+                        "ip": ip,
+                        "netmask": netmask,
+                        "subnet": str(network),
+                    }
+                )
+
+                s.close()
+            except OSError:
+                # Interface doesn't have an IP or other error
+                pass
+            except Exception as e:
+                logger.debug(f"Error getting info for interface {name}: {e}")
+
+    except ImportError:
+        # fcntl not available (Windows)
+        logger.warning("fcntl not available, interface detection limited")
+    except Exception as e:
+        logger.error(f"Error enumerating interfaces: {e}")
+
+    return interfaces
+
+
+def find_interface_for_ip(target_ip: str) -> dict | None:
+    """Find which interface is on the same subnet as the target IP.
+
+    Args:
+        target_ip: IP address to find the matching interface for
+
+    Returns:
+        Interface dict or None if not found
+    """
+    try:
+        target = ipaddress.IPv4Address(target_ip)
+    except ValueError:
+        logger.error(f"Invalid target IP: {target_ip}")
+        return None
+
+    interfaces = get_network_interfaces()
+
+    for iface in interfaces:
+        try:
+            network = ipaddress.IPv4Network(iface["subnet"], strict=False)
+            if target in network:
+                logger.debug(f"Found interface {iface['name']} ({iface['ip']}) for target {target_ip}")
+                return iface
+        except ValueError:
+            continue
+
+    logger.warning(f"No interface found for target IP {target_ip}")
+    return None
+
+
+def get_other_interfaces(exclude_ip: str) -> list[dict]:
+    """Get all interfaces except the one with the given IP.
+
+    Args:
+        exclude_ip: IP address of interface to exclude
+
+    Returns:
+        List of interface dicts
+    """
+    interfaces = get_network_interfaces()
+    return [iface for iface in interfaces if iface["ip"] != exclude_ip]

+ 71 - 22
backend/app/services/print_scheduler.py

@@ -446,6 +446,8 @@ class PrintScheduler:
                                 filament_id = filament_elem.get("id")
                                 filament_type = filament_elem.get("type", "")
                                 filament_color = filament_elem.get("color", "")
+                                # tray_info_idx identifies the specific spool selected when slicing
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
                                 used_g = filament_elem.get("used_g", "0")
                                 try:
                                     used_grams = float(used_g)
@@ -455,6 +457,7 @@ class PrintScheduler:
                                                 "slot_id": int(filament_id),
                                                 "type": filament_type,
                                                 "color": filament_color,
+                                                "tray_info_idx": tray_info_idx,
                                                 "used_grams": round(used_grams, 1),
                                             }
                                         )
@@ -467,6 +470,8 @@ class PrintScheduler:
                         filament_id = filament_elem.get("id")
                         filament_type = filament_elem.get("type", "")
                         filament_color = filament_elem.get("color", "")
+                        # tray_info_idx identifies the specific spool selected when slicing
+                        tray_info_idx = filament_elem.get("tray_info_idx", "")
                         used_g = filament_elem.get("used_g", "0")
                         try:
                             used_grams = float(used_g)
@@ -476,6 +481,7 @@ class PrintScheduler:
                                         "slot_id": int(filament_id),
                                         "type": filament_type,
                                         "color": filament_color,
+                                        "tray_info_idx": tray_info_idx,
                                         "used_grams": round(used_grams, 1),
                                     }
                                 )
@@ -512,6 +518,8 @@ class PrintScheduler:
                 if tray_type:
                     tray_id = tray.get("id", 0)
                     tray_color = tray.get("tray_color", "")
+                    # tray_info_idx identifies the specific spool (e.g., "GFA00", "P4d64437")
+                    tray_info_idx = tray.get("tray_info_idx", "")
                     # Normalize color: remove alpha, add hash
                     color = self._normalize_color(tray_color)
                     # Calculate global tray ID
@@ -521,6 +529,7 @@ class PrintScheduler:
                         {
                             "type": tray_type,
                             "color": color,
+                            "tray_info_idx": tray_info_idx,
                             "ams_id": ams_id,
                             "tray_id": tray_id,
                             "is_ht": is_ht,
@@ -537,6 +546,7 @@ class PrintScheduler:
                 {
                     "type": vt_tray["tray_type"],
                     "color": color,
+                    "tray_info_idx": vt_tray.get("tray_info_idx", ""),
                     "ams_id": -1,
                     "tray_id": 0,
                     "is_ht": False,
@@ -581,11 +591,17 @@ class PrintScheduler:
     def _match_filaments_to_slots(self, required: list[dict], loaded: list[dict]) -> list[int] | None:
         """Match required filaments to loaded filaments and build AMS mapping.
 
-        Priority: exact color match > similar color match > type-only match
+        Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
+
+        The tray_info_idx is a filament type identifier stored in the 3MF file when the user
+        slices (e.g., "GFA00" for generic PLA, "P4d64437" for custom presets). If the same
+        tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays
+        have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color
+        matching among those trays.
 
         Args:
-            required: List of required filaments with slot_id, type, color
-            loaded: List of loaded filaments with type, color, global_tray_id
+            required: List of required filaments with slot_id, type, color, tray_info_idx
+            loaded: List of loaded filaments with type, color, tray_info_idx, global_tray_id
 
         Returns:
             AMS mapping array (position = slot_id - 1, value = global_tray_id or -1)
@@ -600,31 +616,64 @@ class PrintScheduler:
         for req in required:
             req_type = (req.get("type") or "").upper()
             req_color = req.get("color", "")
+            req_tray_info_idx = req.get("tray_info_idx", "")
 
-            # Find best match: exact color > similar color > type-only
+            # Find best match: unique tray_info_idx > exact color > similar color > type-only
+            idx_match = None
             exact_match = None
             similar_match = None
             type_only_match = None
 
-            for f in loaded:
-                if f["global_tray_id"] in used_tray_ids:
-                    continue
-                f_type = (f.get("type") or "").upper()
-                if f_type != req_type:
-                    continue
+            # Get available trays (not already used)
+            available = [f for f in loaded if f["global_tray_id"] not in used_tray_ids]
+
+            # Check if tray_info_idx is unique among available trays
+            if req_tray_info_idx:
+                idx_matches = [f for f in available if f.get("tray_info_idx") == req_tray_info_idx]
+                if len(idx_matches) == 1:
+                    # Unique tray_info_idx - use it as definitive match
+                    idx_match = idx_matches[0]
+                    logger.debug(
+                        f"Matched filament slot {req.get('slot_id')} by unique tray_info_idx={req_tray_info_idx} "
+                        f"-> tray {idx_match['global_tray_id']}"
+                    )
+                elif len(idx_matches) > 1:
+                    # Multiple trays with same tray_info_idx - use color matching among them
+                    logger.debug(
+                        f"Non-unique tray_info_idx={req_tray_info_idx} found in {len(idx_matches)} trays, "
+                        f"using color matching among trays: {[f['global_tray_id'] for f in idx_matches]}"
+                    )
+                    # Use color matching within this subset
+                    for f in idx_matches:
+                        f_color = f.get("color", "")
+                        if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):
+                            if not exact_match:
+                                exact_match = f
+                        elif self._colors_are_similar(f_color, req_color):
+                            if not similar_match:
+                                similar_match = f
+                        elif not type_only_match:
+                            type_only_match = f
+
+            # If no idx_match yet, do standard type/color matching on all available trays
+            if not idx_match and not exact_match and not similar_match and not type_only_match:
+                for f in available:
+                    f_type = (f.get("type") or "").upper()
+                    if f_type != req_type:
+                        continue
 
-                # Type matches - check color
-                f_color = f.get("color", "")
-                if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):
-                    exact_match = f
-                    break  # Best possible match
-                elif self._colors_are_similar(f_color, req_color):
-                    if not similar_match:
-                        similar_match = f
-                elif not type_only_match:
-                    type_only_match = f
-
-            match = exact_match or similar_match or type_only_match
+                    # Type matches - check color
+                    f_color = f.get("color", "")
+                    if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):
+                        if not exact_match:
+                            exact_match = f
+                    elif self._colors_are_similar(f_color, req_color):
+                        if not similar_match:
+                            similar_match = f
+                    elif not type_only_match:
+                        type_only_match = f
+
+            match = idx_match or exact_match or similar_match or type_only_match
             if match:
                 used_tray_ids.add(match["global_tray_id"])
                 comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": match["global_tray_id"]})

+ 116 - 19
backend/app/services/virtual_printer/manager.py

@@ -17,7 +17,7 @@ from backend.app.core.config import settings as app_settings
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
-from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
+from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
 
 logger = logging.getLogger(__name__)
@@ -93,9 +93,11 @@ class VirtualPrinterManager:
         self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
         self._target_printer_ip = ""  # For proxy mode
         self._target_printer_serial = ""  # For proxy mode (real printer's serial)
+        self._remote_interface_ip = ""  # For proxy mode SSDP (LAN B - slicer network)
 
         # Service instances
         self._ssdp: VirtualPrinterSSDPServer | None = None
+        self._ssdp_proxy: SSDPProxy | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
         self._mqtt: SimpleMQTTServer | None = None
         self._proxy: SlicerProxyManager | None = None  # For proxy mode
@@ -108,12 +110,54 @@ class VirtualPrinterManager:
         self._upload_dir = self._base_dir / "uploads"
         self._cert_dir = self._base_dir / "certs"
 
+        # Create directories early to avoid permission issues later
+        # If running in Docker, these need to be on a writable volume
+        self._ensure_directories()
+
         # Certificate service
         self._cert_service = CertificateService(self._cert_dir)
 
         # Track pending uploads for MQTT correlation
         self._pending_files: dict[str, Path] = {}
 
+    def _ensure_directories(self) -> None:
+        """Create and verify virtual printer directories are writable.
+
+        Creates all required directories at startup to catch permission
+        issues early rather than when the user tries to enable features.
+        """
+        dirs_to_create = [
+            self._base_dir,
+            self._upload_dir,
+            self._upload_dir / "cache",
+            self._cert_dir,
+        ]
+
+        logger.info(f"Checking virtual printer directories in {self._base_dir}")
+
+        for dir_path in dirs_to_create:
+            try:
+                dir_path.mkdir(parents=True, exist_ok=True)
+            except PermissionError:
+                logger.error(
+                    f"Cannot create directory {dir_path}: Permission denied. "
+                    f"For Docker: ensure the data volume is writable by the container user. "
+                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
+                )
+                continue
+
+            # Verify directory is writable by attempting to create a test file
+            test_file = dir_path / ".write_test"
+            try:
+                test_file.touch()
+                test_file.unlink(missing_ok=True)
+            except PermissionError:
+                logger.error(
+                    f"Directory {dir_path} exists but is not writable. "
+                    f"For Docker: ensure the data volume is writable by the container user (uid/gid). "
+                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
+                )
+
     def _get_serial_for_model(self, model: str) -> str:
         """Get appropriate serial number for the given model.
 
@@ -157,6 +201,7 @@ class VirtualPrinterManager:
         model: str = "",
         target_printer_ip: str = "",
         target_printer_serial: str = "",
+        remote_interface_ip: str = "",
     ) -> None:
         """Configure and start/stop virtual printer.
 
@@ -167,6 +212,7 @@ class VirtualPrinterManager:
             model: SSDP model code (e.g., 'BL-P001' for X1C)
             target_printer_ip: Target printer IP for proxy mode
             target_printer_serial: Target printer serial for proxy mode
+            remote_interface_ip: IP of interface on slicer network (LAN B) for SSDP proxy
         """
         # Proxy mode has different requirements
         if mode == "proxy":
@@ -183,12 +229,14 @@ class VirtualPrinterManager:
         mode_changed = mode != self._mode
         target_changed = target_printer_ip != self._target_printer_ip
         serial_changed = target_printer_serial != self._target_printer_serial
+        remote_iface_changed = remote_interface_ip != self._remote_interface_ip
         old_mode = self._mode
 
         logger.debug(
             f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
             f"mode={mode}, old_mode={old_mode}, model={model}, new_model={new_model}, "
-            f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}"
+            f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}, "
+            f"remote_interface_ip={remote_interface_ip}"
         )
 
         self._access_code = access_code
@@ -196,8 +244,13 @@ class VirtualPrinterManager:
         self._model = new_model
         self._target_printer_ip = target_printer_ip
         self._target_printer_serial = target_printer_serial
+        self._remote_interface_ip = remote_interface_ip
 
-        needs_restart = model_changed or mode_changed or (mode == "proxy" and (target_changed or serial_changed))
+        needs_restart = (
+            model_changed
+            or mode_changed
+            or (mode == "proxy" and (target_changed or serial_changed or remote_iface_changed))
+        )
 
         if enabled and not self._enabled:
             logger.info("Starting virtual printer (was disabled)")
@@ -247,13 +300,6 @@ class VirtualPrinterManager:
         cert_path, key_path = self._cert_service.generate_certificates()
         logger.info(f"Generated certificate for proxy serial: {proxy_serial}")
 
-        # Initialize SSDP for local discovery using the real printer's serial
-        self._ssdp = VirtualPrinterSSDPServer(
-            name=f"{self.PRINTER_NAME} (Proxy)",
-            serial=proxy_serial,
-            model=self._model,
-        )
-
         # Initialize TLS proxy with our certificates
         self._proxy = SlicerProxyManager(
             target_host=self._target_printer_ip,
@@ -269,21 +315,68 @@ class VirtualPrinterManager:
             except Exception as e:
                 logger.error(f"Virtual printer {name} failed: {e}")
 
-        self._tasks = [
-            asyncio.create_task(
-                run_with_logging(self._ssdp.start(), "SSDP"),
-                name="virtual_printer_ssdp",
-            ),
+        self._tasks = []
+
+        # SSDP setup: use SSDPProxy if remote interface is configured
+        # Local interface is auto-detected from target printer IP
+        if self._remote_interface_ip:
+            # Auto-detect local interface based on target printer IP
+            from backend.app.services.network_utils import find_interface_for_ip
+
+            local_iface = find_interface_for_ip(self._target_printer_ip)
+            if local_iface:
+                local_interface_ip = local_iface["ip"]
+                logger.info(
+                    f"SSDP proxy mode: LAN A ({local_interface_ip}, auto-detected) -> LAN B ({self._remote_interface_ip})"
+                )
+                self._ssdp_proxy = SSDPProxy(
+                    local_interface_ip=local_interface_ip,
+                    remote_interface_ip=self._remote_interface_ip,
+                    target_printer_ip=self._target_printer_ip,
+                )
+                self._tasks.append(
+                    asyncio.create_task(
+                        run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
+                        name="virtual_printer_ssdp_proxy",
+                    )
+                )
+            else:
+                logger.warning(
+                    f"Could not auto-detect local interface for printer {self._target_printer_ip}, "
+                    "falling back to single-interface SSDP"
+                )
+                self._start_fallback_ssdp(proxy_serial, run_with_logging)
+        else:
+            # Single interface: broadcast SSDP on same network (fallback)
+            self._start_fallback_ssdp(proxy_serial, run_with_logging)
+
+        # Add TLS proxy task
+        self._tasks.append(
             asyncio.create_task(
                 run_with_logging(self._proxy.start(), "Proxy"),
                 name="virtual_printer_proxy",
-            ),
-        ]
+            )
+        )
 
         logger.info(
             f"Virtual printer proxy started: "
-            f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} → {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
-            f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} → {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
+            f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
+            f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
+        )
+
+    def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
+        """Start single-interface SSDP server as fallback."""
+        logger.info("SSDP broadcast mode (single interface)")
+        self._ssdp = VirtualPrinterSSDPServer(
+            name=f"{self.PRINTER_NAME} (Proxy)",
+            serial=proxy_serial,
+            model=self._model,
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._ssdp.start(), "SSDP"),
+                name="virtual_printer_ssdp",
+            )
         )
 
     async def _start_server_mode(self) -> None:
@@ -361,6 +454,10 @@ class VirtualPrinterManager:
             await self._ssdp.stop()
             self._ssdp = None
 
+        if self._ssdp_proxy:
+            await self._ssdp_proxy.stop()
+            self._ssdp_proxy = None
+
         if self._proxy:
             await self._proxy.stop()
             self._proxy = None

+ 245 - 20
backend/app/services/virtual_printer/ssdp_server.py

@@ -2,18 +2,23 @@
 
 Responds to M-SEARCH requests from slicers and sends periodic NOTIFY
 announcements so the virtual printer appears as a discoverable Bambu printer.
+
+Also provides SSDP proxy functionality for proxy mode, where Bambuddy sits
+between two networks and re-broadcasts printer SSDP from LAN A to LAN B.
 """
 
 import asyncio
 import logging
+import re
 import socket
 import struct
-from datetime import datetime
 
 logger = logging.getLogger(__name__)
 
-# SSDP multicast address - Bambu uses port 2021
-SSDP_ADDR = "239.255.255.250"
+# SSDP addresses - Bambu uses port 2021
+# Real Bambu printers broadcast to 255.255.255.255, not multicast to 239.255.255.250
+SSDP_MULTICAST_ADDR = "239.255.255.250"
+SSDP_BROADCAST_ADDR = "255.255.255.255"
 SSDP_PORT = 2021
 
 # Bambu service target
@@ -60,44 +65,49 @@ class VirtualPrinterSSDPServer:
             return "127.0.0.1"
 
     def _build_notify_message(self) -> bytes:
-        """Build SSDP NOTIFY message for periodic announcements."""
+        """Build SSDP NOTIFY message for periodic announcements.
+
+        Format matches real Bambu printer SSDP broadcasts observed on the network.
+        Real printers use Host: 239.255.255.250:1990 (port 1990 in header).
+        """
         ip = self._get_local_ip()
-        # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
+        # Match exact format of real Bambu printers (captured via tcpdump)
         # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
         message = (
             "NOTIFY * HTTP/1.1\r\n"
-            f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
-            "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
-            "Cache-Control: max-age=1800\r\n"
+            f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
+            "Server: UPnP/1.0\r\n"
             f"Location: {ip}\r\n"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             "NTS: ssdp:alive\r\n"
-            "EXT:\r\n"
             f"USN: {self.serial}\r\n"
+            "Cache-Control: max-age=1800\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
             f"DevName.bambu.com: {self.name}\r\n"
             "DevSignal.bambu.com: -44\r\n"
             "DevConnect.bambu.com: lan\r\n"
             "DevBind.bambu.com: free\r\n"
             "Devseclink.bambu.com: secure\r\n"
+            "DevInf.bambu.com: eth0\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
+            "DevCap.bambu.com: 1\r\n"
             "\r\n"
         )
         return message.encode()
 
     def _build_response_message(self) -> bytes:
-        """Build SSDP response message for M-SEARCH requests."""
+        """Build SSDP response message for M-SEARCH requests.
+
+        Format matches real Bambu printer SSDP responses.
+        """
         ip = self._get_local_ip()
-        # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
+        # Match format of real Bambu printers
         # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
-        # Added: Devseclink, DevVersion, DevCap for better compatibility
         message = (
             "HTTP/1.1 200 OK\r\n"
-            "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
-            f"Date: {datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n"
+            "Server: UPnP/1.0\r\n"
             f"Location: {ip}\r\n"
             f"ST: {BAMBU_SEARCH_TARGET}\r\n"
-            "EXT:\r\n"
             f"USN: {self.serial}\r\n"
             "Cache-Control: max-age=1800\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
@@ -106,7 +116,9 @@ class VirtualPrinterSSDPServer:
             "DevConnect.bambu.com: lan\r\n"
             "DevBind.bambu.com: free\r\n"
             "Devseclink.bambu.com: secure\r\n"
+            "DevInf.bambu.com: eth0\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
+            "DevCap.bambu.com: 1\r\n"
             "\r\n"
         )
         return message.encode()
@@ -137,7 +149,7 @@ class VirtualPrinterSSDPServer:
             self._socket.bind(("", SSDP_PORT))
 
             # Join multicast group
-            mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
+            mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
             self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
 
             # Enable broadcast
@@ -212,13 +224,14 @@ class VirtualPrinterSSDPServer:
             self._socket = None
 
     async def _send_notify(self) -> None:
-        """Send SSDP NOTIFY message."""
+        """Send SSDP NOTIFY message via broadcast (like real Bambu printers)."""
         if not self._socket:
             return
 
         try:
             msg = self._build_notify_message()
-            self._socket.sendto(msg, (SSDP_ADDR, SSDP_PORT))
+            # Real Bambu printers broadcast to 255.255.255.255, not multicast
+            self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
             logger.debug(f"Sent SSDP NOTIFY for {self.name}")
         except Exception as e:
             logger.debug(f"Failed to send NOTIFY: {e}")
@@ -230,7 +243,7 @@ class VirtualPrinterSSDPServer:
 
         message = (
             "NOTIFY * HTTP/1.1\r\n"
-            f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
+            f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             "NTS: ssdp:byebye\r\n"
             f"USN: {self.serial}\r\n"
@@ -238,7 +251,7 @@ class VirtualPrinterSSDPServer:
         )
 
         try:
-            self._socket.sendto(message.encode(), (SSDP_ADDR, SSDP_PORT))
+            self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT))
             logger.debug("Sent SSDP byebye")
         except Exception:
             pass
@@ -268,3 +281,215 @@ class VirtualPrinterSSDPServer:
                 logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'")
             except Exception as e:
                 logger.debug(f"Failed to send SSDP response: {e}")
+
+
+class SSDPProxy:
+    """SSDP proxy that re-broadcasts printer discovery from one network to another.
+
+    Listens for SSDP broadcasts from a real printer on the local interface (LAN A),
+    then re-broadcasts them on the remote interface (LAN B) with the Location
+    header changed to point to Bambuddy's IP on LAN B.
+
+    This allows Bambu Studio on LAN B to discover the printer via Bambuddy.
+    """
+
+    def __init__(
+        self,
+        local_interface_ip: str,
+        remote_interface_ip: str,
+        target_printer_ip: str,
+    ):
+        """Initialize the SSDP proxy.
+
+        Args:
+            local_interface_ip: IP of interface on printer's network (LAN A)
+            remote_interface_ip: IP of interface on slicer's network (LAN B)
+            target_printer_ip: IP of the real printer to proxy SSDP for
+        """
+        self.local_interface_ip = local_interface_ip
+        self.remote_interface_ip = remote_interface_ip
+        self.target_printer_ip = target_printer_ip
+        self._running = False
+        self._local_socket: socket.socket | None = None
+        self._remote_socket: socket.socket | None = None
+        self._last_printer_ssdp: bytes | None = None
+        self._printer_info: dict[str, str] = {}
+
+    def _parse_ssdp_message(self, data: bytes) -> dict[str, str]:
+        """Parse SSDP message into header dict."""
+        headers = {}
+        try:
+            text = data.decode("utf-8", errors="ignore")
+            for line in text.split("\r\n"):
+                if ":" in line:
+                    key, value = line.split(":", 1)
+                    headers[key.strip().lower()] = value.strip()
+        except Exception:
+            pass
+        return headers
+
+    def _rewrite_ssdp_location(self, data: bytes) -> bytes:
+        """Rewrite SSDP message with Bambuddy's remote IP as Location."""
+        try:
+            text = data.decode("utf-8", errors="ignore")
+            original = text
+            # Replace Location header with our remote interface IP
+            text = re.sub(
+                r"(Location:\s*)[\d.]+",
+                f"\\g<1>{self.remote_interface_ip}",
+                text,
+                flags=re.IGNORECASE,
+            )
+            if text != original:
+                logger.debug(f"Rewrote SSDP Location to {self.remote_interface_ip}")
+                logger.debug(f"Rewritten SSDP packet:\n{text}")
+            else:
+                logger.warning(f"SSDP Location rewrite had no effect. Packet:\n{original}")
+            return text.encode("utf-8")
+        except Exception as e:
+            logger.error(f"Failed to rewrite SSDP: {e}")
+            return data
+
+    async def start(self) -> None:
+        """Start the SSDP proxy."""
+        if self._running:
+            return
+
+        logger.info(
+            f"Starting SSDP proxy: listening on {self.local_interface_ip} (LAN A), "
+            f"broadcasting on {self.remote_interface_ip} (LAN B), "
+            f"proxying printer {self.target_printer_ip}"
+        )
+        self._running = True
+
+        try:
+            # Create socket for listening on LAN A (printer network)
+            # Bind to 0.0.0.0 to receive broadcast packets (255.255.255.255)
+            # We filter by source IP in the handler
+            self._local_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            try:
+                self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+            except (AttributeError, OSError):
+                pass
+            self._local_socket.setblocking(False)
+            # Bind to all interfaces to receive broadcasts
+            self._local_socket.bind(("", SSDP_PORT))
+
+            # Join multicast group on local interface (for multicast SSDP if used)
+            mreq = struct.pack(
+                "4s4s",
+                socket.inet_aton(SSDP_MULTICAST_ADDR),
+                socket.inet_aton(self.local_interface_ip),
+            )
+            self._local_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+            self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            # Create socket for broadcasting on LAN B (slicer network)
+            self._remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            try:
+                self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+            except (AttributeError, OSError):
+                pass
+            self._remote_socket.setblocking(False)
+            # Bind to remote interface
+            self._remote_socket.bind((self.remote_interface_ip, 0))
+            self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            logger.info(f"SSDP proxy listening on 0.0.0.0:{SSDP_PORT} (filtering for printer {self.target_printer_ip})")
+            logger.info(f"SSDP proxy will broadcast on {self.remote_interface_ip}")
+
+            # Main loop
+            last_broadcast = 0.0
+            broadcast_interval = 30.0  # Re-broadcast every 30 seconds
+
+            while self._running:
+                # Listen for SSDP from printer on LAN A
+                try:
+                    data, addr = self._local_socket.recvfrom(4096)
+                    await self._handle_local_packet(data, addr)
+                except BlockingIOError:
+                    pass
+                except Exception as e:
+                    if self._running:
+                        logger.debug(f"SSDP proxy receive error: {e}")
+
+                # Listen for M-SEARCH from slicer on LAN B (via remote socket would need separate bind)
+                # For now, we periodically re-broadcast cached printer SSDP
+                now = asyncio.get_event_loop().time()
+                if self._last_printer_ssdp and now - last_broadcast >= broadcast_interval:
+                    await self._broadcast_to_remote()
+                    last_broadcast = now
+
+                await asyncio.sleep(0.1)
+
+        except OSError as e:
+            logger.error(f"SSDP proxy error: {e}")
+        except asyncio.CancelledError:
+            logger.debug("SSDP proxy cancelled")
+        except Exception as e:
+            logger.error(f"SSDP proxy error: {e}")
+        finally:
+            await self._cleanup()
+
+    async def stop(self) -> None:
+        """Stop the SSDP proxy."""
+        logger.info("Stopping SSDP proxy")
+        self._running = False
+        await self._cleanup()
+
+    async def _cleanup(self) -> None:
+        """Clean up resources."""
+        for sock in [self._local_socket, self._remote_socket]:
+            if sock:
+                try:
+                    sock.close()
+                except Exception:
+                    pass
+        self._local_socket = None
+        self._remote_socket = None
+
+    async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
+        """Handle SSDP packet received on local interface (LAN A)."""
+        sender_ip = addr[0]
+
+        # Only process packets from the target printer
+        if sender_ip != self.target_printer_ip:
+            return
+
+        # Check if it's a NOTIFY message
+        if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
+            return
+
+        # Check if it's a Bambu printer SSDP
+        if b"bambulab-com:device:3dprinter" not in data:
+            return
+
+        # Parse and store printer info
+        headers = self._parse_ssdp_message(data)
+        if headers:
+            self._printer_info = headers
+            logger.debug(f"Received SSDP from printer {sender_ip}: {headers.get('devname.bambu.com', 'unknown')}")
+
+        # Store and immediately broadcast
+        self._last_printer_ssdp = data
+        await self._broadcast_to_remote()
+
+    async def _broadcast_to_remote(self) -> None:
+        """Broadcast cached printer SSDP on remote interface (LAN B)."""
+        if not self._remote_socket or not self._last_printer_ssdp:
+            return
+
+        try:
+            # Rewrite Location to point to Bambuddy's remote interface
+            rewritten = self._rewrite_ssdp_location(self._last_printer_ssdp)
+
+            # Calculate broadcast address for remote network
+            # Use 255.255.255.255 for simplicity (works across subnets)
+            self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT))
+
+            printer_name = self._printer_info.get("devname.bambu.com", "unknown")
+            logger.debug(f"Broadcast SSDP for '{printer_name}' on LAN B ({self.remote_interface_ip})")
+        except Exception as e:
+            logger.debug(f"Failed to broadcast SSDP on remote: {e}")

+ 3 - 2
backend/app/services/virtual_printer/tcp_proxy.py

@@ -289,8 +289,9 @@ class SlicerProxyManager:
     PRINTER_FTP_PORT = 990
     PRINTER_MQTT_PORT = 8883
 
-    # Local listen ports (same as virtual printer)
-    LOCAL_FTP_PORT = 9990
+    # Local listen ports - must match what Bambu Studio expects
+    # Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability
+    LOCAL_FTP_PORT = 990
     LOCAL_MQTT_PORT = 8883
 
     def __init__(

+ 163 - 2
backend/tests/unit/services/test_virtual_printer.py

@@ -464,7 +464,8 @@ class TestSlicerProxyManager:
 
     def test_proxy_manager_initializes_ports(self, proxy_manager):
         """Verify proxy manager has correct port constants."""
-        assert proxy_manager.LOCAL_FTP_PORT == 9990
+        # FTP proxy uses privileged port 990 to match what Bambu Studio expects
+        assert proxy_manager.LOCAL_FTP_PORT == 990
         assert proxy_manager.LOCAL_MQTT_PORT == 8883
         assert proxy_manager.PRINTER_FTP_PORT == 990
         assert proxy_manager.PRINTER_MQTT_PORT == 8883
@@ -482,6 +483,125 @@ class TestSlicerProxyManager:
         assert status["mqtt_connections"] == 0
 
 
+class TestSSDPProxy:
+    """Tests for SSDPProxy (cross-network SSDP relay)."""
+
+    @pytest.fixture
+    def ssdp_proxy(self):
+        """Create an SSDPProxy instance."""
+        from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
+
+        return SSDPProxy(
+            local_interface_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.100",
+            target_printer_ip="192.168.1.50",
+        )
+
+    def test_ssdp_proxy_stores_interface_ips(self, ssdp_proxy):
+        """Verify SSDPProxy stores interface IPs correctly."""
+        assert ssdp_proxy.local_interface_ip == "192.168.1.100"
+        assert ssdp_proxy.remote_interface_ip == "10.0.0.100"
+        assert ssdp_proxy.target_printer_ip == "192.168.1.50"
+
+    def test_rewrite_ssdp_location(self, ssdp_proxy):
+        """Verify SSDP Location header is rewritten to remote interface IP."""
+        original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
+
+        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+
+        # Location should be changed to remote interface IP
+        assert b"Location: 10.0.0.100" in rewritten
+        assert b"Location: 192.168.1.50" not in rewritten
+        # Other headers should be preserved
+        assert b"DevName.bambu.com: TestPrinter" in rewritten
+
+    def test_rewrite_ssdp_location_case_insensitive(self, ssdp_proxy):
+        """Verify SSDP Location rewrite is case insensitive."""
+        original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
+
+        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+
+        assert b"10.0.0.100" in rewritten
+
+    def test_rewrite_ssdp_location_no_match(self, ssdp_proxy):
+        """Verify packet without Location header is returned unchanged."""
+        original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
+
+        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+
+        # Should be unchanged (no Location header to rewrite)
+        assert rewritten == original_packet
+
+    def test_parse_ssdp_message(self, ssdp_proxy):
+        """Verify SSDP message parsing extracts headers."""
+        packet = (
+            b"NOTIFY * HTTP/1.1\r\n"
+            b"Location: 192.168.1.50\r\n"
+            b"DevName.bambu.com: TestPrinter\r\n"
+            b"DevModel.bambu.com: BL-P001\r\n"
+            b"\r\n"
+        )
+
+        headers = ssdp_proxy._parse_ssdp_message(packet)
+
+        assert headers["location"] == "192.168.1.50"
+        assert headers["devname.bambu.com"] == "TestPrinter"
+        assert headers["devmodel.bambu.com"] == "BL-P001"
+
+
+class TestVirtualPrinterManagerDirectories:
+    """Tests for VirtualPrinterManager directory management."""
+
+    def test_ensure_directories_creates_subdirs(self, tmp_path):
+        """Verify _ensure_directories creates all required subdirectories."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+        # Create a manager and manually call _ensure_directories with our tmp path
+        manager = VirtualPrinterManager()
+        # Override the paths
+        manager._base_dir = tmp_path / "virtual_printer"
+        manager._upload_dir = manager._base_dir / "uploads"
+        manager._cert_dir = manager._base_dir / "certs"
+
+        # Call the method
+        manager._ensure_directories()
+
+        # All directories should be created
+        assert (tmp_path / "virtual_printer").exists()
+        assert (tmp_path / "virtual_printer" / "uploads").exists()
+        assert (tmp_path / "virtual_printer" / "uploads" / "cache").exists()
+        assert (tmp_path / "virtual_printer" / "certs").exists()
+
+    def test_ensure_directories_handles_permission_error(self, tmp_path, caplog):
+        """Verify _ensure_directories logs error on permission failure."""
+        import logging
+        from unittest.mock import patch
+
+        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+        # Create manager and override paths
+        manager = VirtualPrinterManager()
+        vp_dir = tmp_path / "virtual_printer"
+
+        manager._base_dir = vp_dir
+        manager._upload_dir = vp_dir / "uploads"
+        manager._cert_dir = vp_dir / "certs"
+
+        # Mock mkdir to raise PermissionError (chmod doesn't work as root in Docker)
+        original_mkdir = type(vp_dir).mkdir
+
+        def mock_mkdir(self, *args, **kwargs):
+            if "virtual_printer" in str(self):
+                raise PermissionError("Permission denied")
+            return original_mkdir(self, *args, **kwargs)
+
+        with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), "mkdir", mock_mkdir):
+            # This should log errors but not raise
+            manager._ensure_directories()
+            # Check that error was logged
+            assert "Permission denied" in caplog.text
+
+
 class TestVirtualPrinterManagerProxyMode:
     """Tests for VirtualPrinterManager proxy mode."""
 
@@ -528,7 +648,7 @@ class TestVirtualPrinterManagerProxyMode:
         mock_proxy = MagicMock()
         mock_proxy.get_status.return_value = {
             "running": True,
-            "ftp_port": 9990,
+            "ftp_port": 990,  # Privileged port for Bambu Studio compatibility
             "mqtt_port": 8883,
             "ftp_connections": 1,
             "mqtt_connections": 2,
@@ -541,5 +661,46 @@ class TestVirtualPrinterManagerProxyMode:
         assert status["mode"] == "proxy"
         assert status["target_printer_ip"] == "192.168.1.100"
         assert "proxy" in status
+        assert status["proxy"]["ftp_port"] == 990  # Privileged port for Bambu Studio compatibility
+        assert status["proxy"]["mqtt_port"] == 8883
         assert status["proxy"]["ftp_connections"] == 1
         assert status["proxy"]["mqtt_connections"] == 2
+
+    @pytest.mark.asyncio
+    async def test_configure_proxy_mode_with_remote_interface(self, manager):
+        """Verify proxy mode accepts remote_interface_ip for SSDP proxy."""
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            mode="proxy",
+            target_printer_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.50",
+        )
+
+        assert manager._mode == "proxy"
+        assert manager._target_printer_ip == "192.168.1.100"
+        assert manager._remote_interface_ip == "10.0.0.50"
+
+    @pytest.mark.asyncio
+    async def test_configure_proxy_mode_restarts_on_remote_interface_change(self, manager):
+        """Verify changing remote_interface_ip restarts services."""
+        # Simulate running state
+        manager._enabled = True
+        manager._mode = "proxy"
+        manager._target_printer_ip = "192.168.1.100"
+        manager._remote_interface_ip = "10.0.0.50"
+        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+        manager._stop = AsyncMock()
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            mode="proxy",
+            target_printer_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.99",  # Changed
+        )
+
+        # Should have stopped and started
+        manager._stop.assert_called_once()
+        manager._start.assert_called_once()

+ 202 - 0
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -265,3 +265,205 @@ class TestMatchFilamentsToSlots:
 
         result = scheduler._match_filaments_to_slots(required, loaded)
         assert result == [254]
+
+    def test_match_by_tray_info_idx_priority(self, scheduler):
+        """tray_info_idx match should have highest priority over color match."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#000000",
+                "global_tray_id": 0,
+                "tray_info_idx": "GFB00",
+            },  # Same color, different spool
+            {
+                "type": "PLA",
+                "color": "#000000",
+                "global_tray_id": 1,
+                "tray_info_idx": "GFA00",
+            },  # Same color, exact spool
+            {
+                "type": "PLA",
+                "color": "#000000",
+                "global_tray_id": 2,
+                "tray_info_idx": "GFC00",
+            },  # Same color, different spool
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [1]  # Should pick tray 1 (exact tray_info_idx match)
+
+    def test_match_by_tray_info_idx_with_different_colors(self, scheduler):
+        """tray_info_idx match should work even if colors differ slightly."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "P4d64437"}]
+        loaded = [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": ""},  # No idx
+            {
+                "type": "PLA",
+                "color": "#000010",
+                "global_tray_id": 3,
+                "tray_info_idx": "P4d64437",
+            },  # Exact spool (slightly different color reported)
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [3]  # Should pick tray 3 (exact tray_info_idx match)
+
+    def test_match_fallback_to_color_when_no_tray_info_idx(self, scheduler):
+        """Should fall back to color matching when tray_info_idx is empty."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": ""}]
+        loaded = [
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 0, "tray_info_idx": "GFA00"},
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 1, "tray_info_idx": "GFB00"},  # Color match
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [1]  # Should pick tray 1 (color match)
+
+    def test_match_fallback_to_color_when_no_matching_tray_info_idx(self, scheduler):
+        """Should fall back to color when tray_info_idx doesn't match any loaded spool."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "OLD_SPOOL"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "global_tray_id": 0,
+                "tray_info_idx": "NEW_SPOOL",
+            },  # Different idx but same color
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0]  # Should fall back to color match
+
+    def test_match_multiple_same_color_with_tray_info_idx(self, scheduler):
+        """Multiple identical filaments should be matched by tray_info_idx (H2D Pro scenario)."""
+        # This is the exact scenario from issue #245 - 3 black PLA spools
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA03"},  # Wants tray 3
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00"},  # Tray 0
+            {"type": "PLA", "color": "#000000", "global_tray_id": 1, "tray_info_idx": "GFA01"},  # Tray 1
+            {"type": "PLA", "color": "#000000", "global_tray_id": 2, "tray_info_idx": "GFA02"},  # Tray 2
+            {
+                "type": "PLA",
+                "color": "#000000",
+                "global_tray_id": 3,
+                "tray_info_idx": "GFA03",
+            },  # Tray 3 - the one we want
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [3]  # Should pick tray 3, not tray 0
+
+    def test_match_tray_info_idx_not_reused(self, scheduler):
+        """tray_info_idx matched trays should not be reused for other slots."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"},
+            {"slot_id": 2, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA01"},
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00"},
+            {"type": "PLA", "color": "#000000", "global_tray_id": 1, "tray_info_idx": "GFA01"},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0, 1]  # Each slot gets its specific tray
+
+    def test_match_non_unique_tray_info_idx_uses_color(self, scheduler):
+        """Non-unique tray_info_idx should fall back to color matching.
+
+        This is the scenario where multiple trays have the same tray_info_idx
+        (e.g., two spools of generic PLA both have GFA00). The color should
+        be used as tiebreaker instead of just picking the first match.
+        """
+        # User sliced with green PLA (tray_info_idx=GFA00)
+        # Two trays have GFA00: tray 3 (white) and tray 4 (green)
+        # Should pick tray 4 because the color matches
+        required = [
+            {"slot_id": 2, "type": "PLA", "color": "#00FF00", "tray_info_idx": "GFA00"},  # Green PLA
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#FFFFFF", "global_tray_id": 3, "tray_info_idx": "GFA00"},  # White PLA
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 4, "tray_info_idx": "GFA00"},  # Green PLA
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [-1, 4]  # Should pick tray 4 (color match), not tray 3 (first match)
+
+    def test_match_non_unique_tray_info_idx_same_color(self, scheduler):
+        """Non-unique tray_info_idx with identical colors picks first match.
+
+        When multiple trays have the same tray_info_idx AND same color,
+        there's no way to differentiate, so first match is used.
+        """
+        required = [
+            {"slot_id": 2, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA00"},
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#FFFFFF", "global_tray_id": 3, "tray_info_idx": "GFA00"},
+            {"type": "PLA", "color": "#FFFFFF", "global_tray_id": 4, "tray_info_idx": "GFA00"},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Both have same color, so first is used
+        assert result == [-1, 3]
+
+
+class TestBuildLoadedFilamentsTrayInfoIdx:
+    """Test tray_info_idx extraction in _build_loaded_filaments."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_build_loaded_filaments_includes_tray_info_idx(self, scheduler):
+        """Should extract tray_info_idx from AMS trays."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "000000", "tray_info_idx": "GFA00"},
+                            {"id": 1, "tray_type": "PLA", "tray_color": "000000", "tray_info_idx": "GFA01"},
+                        ],
+                    }
+                ]
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 2
+        assert result[0]["tray_info_idx"] == "GFA00"
+        assert result[1]["tray_info_idx"] == "GFA01"
+
+    def test_build_loaded_filaments_empty_tray_info_idx(self, scheduler):
+        """Missing tray_info_idx should default to empty string."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "FF0000"},  # No tray_info_idx
+                        ],
+                    }
+                ]
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["tray_info_idx"] == ""
+
+    def test_build_loaded_filaments_external_spool_tray_info_idx(self, scheduler):
+        """Should extract tray_info_idx from external spool."""
+
+        class MockStatus:
+            raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}}
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["tray_info_idx"] == "P4d64437"
+        assert result[0]["is_external"] is True

+ 25 - 2
frontend/src/__tests__/api/client.test.ts

@@ -95,11 +95,11 @@ describe('API Client Auth Header', () => {
     expect(capturedHeaders!.get('Authorization')).toBeNull();
   });
 
-  it('clears token on 401 Unauthorized response', async () => {
+  it('clears token on 401 with invalid token message', async () => {
     server.use(
       http.get('/api/v1/settings/spoolman', () => {
         return HttpResponse.json(
-          { detail: 'Not authenticated' },
+          { detail: 'Could not validate credentials' },
           { status: 401 }
         );
       })
@@ -117,6 +117,29 @@ describe('API Client Auth Header', () => {
     expect(getAuthToken()).toBeNull();
     expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
   });
+
+  it('does not clear token on 401 with generic auth error', async () => {
+    server.use(
+      http.get('/api/v1/settings/spoolman', () => {
+        return HttpResponse.json(
+          { detail: 'Authentication required' },
+          { status: 401 }
+        );
+      })
+    );
+
+    setAuthToken('valid-token');
+    expect(getAuthToken()).toBe('valid-token');
+
+    try {
+      await api.getSpoolmanSettings();
+    } catch {
+      // Expected to throw
+    }
+
+    // Token should NOT be cleared for generic auth errors (might be timing issue)
+    expect(getAuthToken()).toBe('valid-token');
+  });
 });
 
 describe('FormData requests include auth header', () => {

+ 2 - 1
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -43,6 +43,7 @@ const createMockSettings = (overrides = {}) => ({
   mode: 'immediate' as const,
   model: '3DPrinter-X1-Carbon',
   target_printer_id: null as number | null,
+  remote_interface_ip: null as string | null,
   status: {
     enabled: false,
     running: false,
@@ -515,7 +516,7 @@ describe('VirtualPrinterSettings', () => {
             proxy: {
               running: true,
               target_host: '192.168.1.100',
-              ftp_port: 9990,
+              ftp_port: 990,  // Privileged port for Bambu Studio compatibility
               mqtt_port: 8883,
               ftp_connections: 1,
               mqtt_connections: 2,

+ 349 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -0,0 +1,349 @@
+/**
+ * Tests for the useFilamentMapping hook and helper functions.
+ *
+ * Tests the tray_info_idx matching logic that ensures the exact spool
+ * selected during slicing is used when multiple trays have identical filament.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildLoadedFilaments,
+  computeAmsMapping,
+} from '../../hooks/useFilamentMapping';
+import type { PrinterStatus } from '../../api/client';
+
+// Helper to create a minimal printer status with AMS data
+function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray?: PrinterStatus['vt_tray']): PrinterStatus {
+  return {
+    ams,
+    vt_tray,
+  } as PrinterStatus;
+}
+
+describe('buildLoadedFilaments', () => {
+  it('returns empty array for undefined status', () => {
+    const result = buildLoadedFilaments(undefined);
+    expect(result).toEqual([]);
+  });
+
+  it('extracts filaments from AMS units', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
+          { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFA01' },
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result).toHaveLength(2);
+    expect(result[0]).toMatchObject({
+      type: 'PLA',
+      color: '#FF0000',
+      amsId: 0,
+      trayId: 0,
+      globalTrayId: 0,
+      trayInfoIdx: 'GFA00',
+    });
+    expect(result[1]).toMatchObject({
+      type: 'PETG',
+      color: '#00FF00',
+      globalTrayId: 1,
+      trayInfoIdx: 'GFA01',
+    });
+  });
+
+  it('includes tray_info_idx from AMS trays', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'P4d64437' },
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].trayInfoIdx).toBe('P4d64437');
+  });
+
+  it('handles missing tray_info_idx', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // No tray_info_idx
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].trayInfoIdx).toBe('');
+  });
+
+  it('extracts external spool with tray_info_idx', () => {
+    const status = createPrinterStatus(
+      [],
+      { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
+    );
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result).toHaveLength(1);
+    expect(result[0]).toMatchObject({
+      type: 'TPU',
+      isExternal: true,
+      globalTrayId: 254,
+      trayInfoIdx: 'EXT001',
+    });
+  });
+
+  it('skips empty trays', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
+          { id: 1, tray_type: '', tray_color: '' },  // Empty tray
+          { id: 2 },  // No tray_type
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result).toHaveLength(1);
+    expect(result[0].type).toBe('PLA');
+  });
+
+  it('marks AMS-HT units correctly', () => {
+    const status = createPrinterStatus([
+      {
+        id: 128,  // AMS-HT typically has high ID
+        tray: [
+          { id: 0, tray_type: 'PLA-CF', tray_color: '000000', tray_info_idx: 'HT001' },
+        ],  // Single tray = AMS-HT
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].isHt).toBe(true);
+    expect(result[0].globalTrayId).toBe(512);  // 128 * 4 + 0
+  });
+});
+
+describe('computeAmsMapping', () => {
+  it('returns undefined for empty filament requirements', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+
+    expect(computeAmsMapping(undefined, status)).toBeUndefined();
+    expect(computeAmsMapping({ filaments: [] }, status)).toBeUndefined();
+  });
+
+  it('returns undefined when no filaments loaded', () => {
+    const reqs = {
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
+    };
+
+    expect(computeAmsMapping(reqs, undefined)).toBeUndefined();
+    expect(computeAmsMapping(reqs, createPrinterStatus([]))).toBeUndefined();
+  });
+
+  it('matches by tray_info_idx with highest priority', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA01' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },  // Same color, wrong idx
+          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },  // Exact idx match
+          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },  // Same color, wrong idx
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([1]);  // Should pick tray 1, not tray 0
+  });
+
+  it('matches multiple identical filaments by tray_info_idx (H2D Pro scenario)', () => {
+    // This is the exact scenario from issue #245 - multiple black PLA spools
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 50, tray_info_idx: 'GFA03' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
+          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
+          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
+          { id: 3, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA03' },  // This one
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([3]);  // Should pick tray 3, not tray 0
+  });
+
+  it('falls back to color match when tray_info_idx is empty', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: '' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '00FF00', tray_info_idx: 'GFA00' },  // Wrong color
+          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA01' },  // Color match
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([1]);
+  });
+
+  it('falls back to color match when tray_info_idx does not match', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'OLD_SPOOL' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'NEW_SPOOL' },  // Different idx, same color
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // Falls back to color match
+  });
+
+  it('matches by type only when color differs', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '0000FF' },  // Same type, different color
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // Type-only match
+  });
+
+  it('returns -1 for unmatched slots', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'TPU', color: '#FF0000', used_grams: 10 },  // No TPU loaded
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([-1]);
+  });
+
+  it('avoids duplicate tray assignment', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
+        { slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 },  // Same requirements
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // Only one PLA
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0, -1]);  // First slot gets the match, second is unmatched
+  });
+
+  it('handles multi-slot mapping with tray_info_idx', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA00' },
+        { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA02' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
+          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
+          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0, 2]);  // Each slot gets its specific tray
+  });
+
+  it('handles external spool matching', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'TPU', color: '#0000FF', used_grams: 10, tray_info_idx: 'EXT001' },
+      ],
+    };
+    const status = createPrinterStatus(
+      [],
+      { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
+    );
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([254]);  // External spool global ID
+  });
+});

+ 144 - 57
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -77,6 +77,20 @@ const mockFiles = [
     duplicate_count: 2,
     created_at: '2024-01-02T00:00:00Z',
   },
+  {
+    id: 3,
+    filename: 'cube.gcode.3mf',
+    file_path: '/library/cube.gcode.3mf',
+    file_size: 2048576,
+    file_type: '3mf',
+    folder_id: null,
+    thumbnail_path: '/thumbnails/3.png',
+    print_name: 'Cube',
+    print_time_seconds: 1800,
+    print_count: 2,
+    duplicate_count: 0,
+    created_at: '2024-01-03T00:00:00Z',
+  },
 ];
 
 const mockStats = {
@@ -459,8 +473,27 @@ describe('FileManagerPage', () => {
     });
   });
 
-  describe('add to queue', () => {
-    it('shows add to queue button for sliced files', async () => {
+  describe('schedule print', () => {
+    it('shows schedule print button when one sliced file is selected', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      // Select a sliced file (benchy.gcode.3mf) by clicking on its card
+      const fileCard = screen.getByText('Benchy').closest('div[class*="cursor-pointer"]');
+      if (fileCard) {
+        await user.click(fileCard);
+      }
+
+      await waitFor(() => {
+        expect(screen.getByText(/Schedule/)).toBeInTheDocument();
+      });
+    });
+
+    it('hides schedule print button when multiple files are selected', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
 
@@ -468,11 +501,12 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Select All')).toBeInTheDocument();
       });
 
-      // Select a sliced file (benchy.gcode.3mf)
+      // Select all files
       await user.click(screen.getByText('Select All'));
 
       await waitFor(() => {
-        expect(screen.getByText(/Add to Queue/)).toBeInTheDocument();
+        // Schedule button should not be present when multiple files are selected
+        expect(screen.queryByText(/Schedule/)).not.toBeInTheDocument();
       });
     });
   });
@@ -535,7 +569,7 @@ describe('FileManagerPage', () => {
     });
   });
 
-  describe('upload modal STL options', () => {
+  describe('upload modal with advanced 3MF support', () => {
     it('opens upload modal', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -552,7 +586,7 @@ describe('FileManagerPage', () => {
       });
     });
 
-    it('shows STL thumbnail option when STL file is added', async () => {
+    it('shows 3MF extraction info when 3MF file is added', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
 
@@ -566,24 +600,24 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
       });
 
-      // Create a mock STL file
-      const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
+      // Create a mock 3MF file
+      const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
 
       // Get the hidden file input
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
       expect(fileInput).toBeInTheDocument();
 
       // Simulate file selection
-      await user.upload(fileInput, stlFile);
+      await user.upload(fileInput, threemfFile);
 
-      // STL thumbnail option should appear
+      // 3MF extraction info should appear
       await waitFor(() => {
-        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
-        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+        expect(screen.getByText('3MF files detected')).toBeInTheDocument();
+        expect(screen.getByText(/Printer model.*will be automatically extracted/i)).toBeInTheDocument();
       });
     });
 
-    it('STL thumbnail checkbox is checked by default', async () => {
+    it('shows STL thumbnail option when STL file is added', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
 
@@ -597,79 +631,132 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
       });
 
-      // Add an STL file
+      // Create a mock STL file
       const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
+
+      // Get the hidden file input
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      expect(fileInput).toBeInTheDocument();
+
+      // Simulate file selection
       await user.upload(fileInput, stlFile);
 
+      // STL thumbnail option should appear
       await waitFor(() => {
-        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
       });
-
-      // Checkbox should be checked by default
-      const checkbox = screen.getByRole('checkbox', { name: /Generate thumbnails for STL files/i });
-      expect(checkbox).toBeChecked();
     });
+  });
 
-    it('can toggle STL thumbnail checkbox', async () => {
-      const user = userEvent.setup();
-      render(<FileManagerPage />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Upload')).toBeInTheDocument();
-      });
+  describe('authentication-based UI changes', () => {
+    it('hides "Uploaded By" column and user filter when auth is disabled', async () => {
+      // Mock auth disabled (default)
+      server.use(
+        http.get('*/api/v1/auth/status', () => {
+          return HttpResponse.json({
+            auth_enabled: false,
+            requires_setup: false,
+          });
+        }),
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([
+            {
+              id: 1,
+              filename: 'test.3mf',
+              file_path: '/library/test.3mf',
+              file_size: 1048576,
+              file_type: '3mf',
+              folder_id: null,
+              thumbnail_path: null,
+              print_name: 'Test File',
+              print_time_seconds: 3600,
+              print_count: 0,
+              duplicate_count: 0,
+              created_at: '2024-01-01T00:00:00Z',
+              created_by_username: 'testuser',
+            },
+          ]);
+        })
+      );
 
-      await user.click(screen.getByText('Upload'));
+      render(<FileManagerPage />);
 
+      // Switch to list view to see the column headers
       await waitFor(() => {
-        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+        expect(screen.getByText('Test File')).toBeInTheDocument();
       });
 
-      // Add an STL file
-      const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
-      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
-      await user.upload(fileInput, stlFile);
+      const user = userEvent.setup();
+      const listViewButton = screen.getByRole('button', { name: /list/i });
+      await user.click(listViewButton);
 
+      // "Uploaded By" column header should not be present
       await waitFor(() => {
-        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+        expect(screen.queryByText('Uploaded By')).not.toBeInTheDocument();
       });
 
-      const checkbox = screen.getByRole('checkbox', { name: /Generate thumbnails for STL files/i });
-      expect(checkbox).toBeChecked();
-
-      // Toggle off
-      await user.click(checkbox);
-      expect(checkbox).not.toBeChecked();
-
-      // Toggle back on
-      await user.click(checkbox);
-      expect(checkbox).toBeChecked();
+      // User filter dropdown should not be present
+      expect(screen.queryByPlaceholderText('Filter by user')).not.toBeInTheDocument();
     });
 
-    it('shows STL thumbnail option for ZIP files', async () => {
-      const user = userEvent.setup();
+    it('shows "Uploaded By" column and user filter when auth is enabled', async () => {
+      // Mock auth enabled
+      server.use(
+        http.get('*/api/v1/auth/status', () => {
+          return HttpResponse.json({
+            auth_enabled: true,
+            requires_setup: false,
+          });
+        }),
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([
+            {
+              id: 1,
+              filename: 'test.3mf',
+              file_path: '/library/test.3mf',
+              file_size: 1048576,
+              file_type: '3mf',
+              folder_id: null,
+              thumbnail_path: null,
+              print_name: 'Test File',
+              print_time_seconds: 3600,
+              print_count: 0,
+              duplicate_count: 0,
+              created_at: '2024-01-01T00:00:00Z',
+              created_by_username: 'testuser',
+            },
+          ]);
+        }),
+        http.get('/api/v1/users/', () => {
+          return HttpResponse.json([
+            { id: 1, username: 'testuser' },
+            { id: 2, username: 'admin' },
+          ]);
+        })
+      );
+
       render(<FileManagerPage />);
 
+      // Switch to list view to see the column headers
       await waitFor(() => {
-        expect(screen.getByText('Upload')).toBeInTheDocument();
+        expect(screen.getByText('Test File')).toBeInTheDocument();
       });
 
-      await user.click(screen.getByText('Upload'));
+      const user = userEvent.setup();
+      const listViewButton = screen.getByRole('button', { name: /list/i });
+      await user.click(listViewButton);
 
+      // "Uploaded By" column header should be present
       await waitFor(() => {
-        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+        expect(screen.getByText('Uploaded By')).toBeInTheDocument();
       });
 
-      // Create a mock ZIP file
-      const zipFile = new File(['PK'], 'models.zip', { type: 'application/zip' });
-      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
-      await user.upload(fileInput, zipFile);
+      // User filter dropdown should be present
+      expect(screen.getByPlaceholderText('Filter by user')).toBeInTheDocument();
 
-      // STL thumbnail option should appear for ZIP files (may contain STLs)
-      await waitFor(() => {
-        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
-        expect(screen.getByText(/ZIP files may contain STL files/)).toBeInTheDocument();
-      });
+      // Username should be displayed in the column
+      expect(screen.getByText('testuser')).toBeInTheDocument();
     });
   });
 });

+ 36 - 9
frontend/src/api/client.ts

@@ -39,16 +39,27 @@ async function request<T>(
   });
 
   if (!response.ok) {
-    // Handle 401 Unauthorized - clear token and redirect to login
-    if (response.status === 401) {
-      setAuthToken(null);
-      // Don't throw here - let the auth context handle redirect
-    }
     const error = await response.json().catch(() => ({}));
     const detail = error.detail;
     const message = typeof detail === 'string'
       ? detail
       : (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);
+
+    // Handle 401 Unauthorized - only clear token if it's actually invalid
+    // Don't clear on "Authentication required" which might be a timing issue
+    if (response.status === 401) {
+      const invalidTokenMessages = [
+        'Could not validate credentials',
+        'Token has expired',
+        'User not found or inactive',
+        'Invalid API key',
+        'API key has expired',
+      ];
+      if (invalidTokenMessages.some(m => message.includes(m))) {
+        setAuthToken(null);
+      }
+    }
+
     throw new Error(message);
   }
 
@@ -2722,6 +2733,8 @@ export const api = {
   },
   checkFfmpeg: () =>
     request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
+  getNetworkInterfaces: () =>
+    request<{ interfaces: NetworkInterface[] }>('/settings/network-interfaces'),
 
   // Cloud
   getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
@@ -2803,13 +2816,11 @@ export const api = {
 
   // Tasmota Discovery (auto-detects network)
   startTasmotaScan: () =>
-    fetch(`${API_BASE}/smart-plugs/discover/scan`, { method: 'POST' })
-      .then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
+    request<TasmotaScanStatus>('/smart-plugs/discover/scan', { method: 'POST' }),
   getTasmotaScanStatus: () =>
     request<TasmotaScanStatus>('/smart-plugs/discover/status'),
   stopTasmotaScan: () =>
-    fetch(`${API_BASE}/smart-plugs/discover/stop`, { method: 'POST' })
-      .then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
+    request<TasmotaScanStatus>('/smart-plugs/discover/stop', { method: 'POST' }),
   getDiscoveredTasmotaDevices: () =>
     request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
 
@@ -3791,6 +3802,11 @@ export interface LibraryFile {
   created_by_username: string | null;
   created_at: string;
   updated_at: string;
+  // Metadata fields
+  print_name: string | null;
+  print_time_seconds: number | null;
+  filament_used_grams: number | null;
+  sliced_for_model: string | null;
 }
 
 export interface LibraryFileListItem {
@@ -3809,6 +3825,7 @@ export interface LibraryFileListItem {
   print_name: string | null;
   print_time_seconds: number | null;
   filament_used_grams: number | null;
+  sliced_for_model: string | null;
 }
 
 export interface LibraryFileUpdate {
@@ -3975,9 +3992,17 @@ export interface VirtualPrinterSettings {
   mode: VirtualPrinterMode;
   model: string;
   target_printer_id: number | null;  // For proxy mode
+  remote_interface_ip: string | null;  // For SSDP proxy across networks
   status: VirtualPrinterStatus;
 }
 
+export interface NetworkInterface {
+  name: string;
+  ip: string;
+  netmask: string;
+  subnet: string;
+}
+
 export interface VirtualPrinterModels {
   models: Record<string, string>;  // SSDP code -> display name
   default: string;
@@ -4007,6 +4032,7 @@ export const virtualPrinterApi = {
     mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
     model?: string;
     target_printer_id?: number;
+    remote_interface_ip?: string;
   }) => {
     const params = new URLSearchParams();
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
@@ -4014,6 +4040,7 @@ export const virtualPrinterApi = {
     if (data.mode !== undefined) params.set('mode', data.mode);
     if (data.model !== undefined) params.set('model', data.model);
     if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
+    if (data.remote_interface_ip !== undefined) params.set('remote_interface_ip', data.remote_interface_ip);
 
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
       method: 'PUT',

+ 4 - 2
frontend/src/components/FilamentHoverCard.tsx

@@ -87,10 +87,12 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
     if (isVisible && triggerRef.current && cardRef.current) {
       const triggerRect = triggerRef.current.getBoundingClientRect();
       const cardHeight = cardRef.current.offsetHeight;
-      const spaceAbove = triggerRect.top;
+      // Account for fixed header (56px) - space above should exclude header area
+      const headerHeight = 56;
+      const spaceAbove = triggerRect.top - headerHeight;
       const spaceBelow = window.innerHeight - triggerRect.bottom;
 
-      // Prefer top, but flip to bottom if not enough space
+      // Prefer top, but flip to bottom if not enough space (accounting for header)
       if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {
         setPosition('bottom');
       } else {

+ 2 - 2
frontend/src/components/FilamentTrends.tsx

@@ -143,7 +143,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
 
       months.push({
         month: monthStr,
-        filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0) * (a.quantity || 1), 0)),
+        filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0)),
         cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
         prints: monthArchives.reduce((sum, a) => sum + (a.quantity || 1), 0),
       });
@@ -153,7 +153,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
   }, [archives]);
 
   const chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
-  const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0) * (a.quantity || 1), 0);
+  const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
   const totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
   const totalPrints = filteredArchives.reduce((sum, a) => sum + (a.quantity || 1), 0);
 

+ 11 - 2
frontend/src/components/PrintModal/index.tsx

@@ -10,6 +10,7 @@ import { useToast } from '../../contexts/ToastContext';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { isPlaceholderDate } from '../../utils/amsHelpers';
+import { toDateTimeLocalValue } from '../../utils/date';
 import { PrinterSelector } from './PrinterSelector';
 import { PlateSelector } from './PlateSelector';
 import { FilamentMapping } from './FilamentMapping';
@@ -90,7 +91,8 @@ export function PrintModal({
       let scheduledTime = '';
       if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
         const date = new Date(queueItem.scheduled_time);
-        scheduledTime = date.toISOString().slice(0, 16);
+        // Use toDateTimeLocalValue to convert UTC to local time for datetime-local input
+        scheduledTime = toDateTimeLocalValue(date);
       }
 
       return {
@@ -180,8 +182,15 @@ export function PrintModal({
     enabled: !!archiveId && !isLibraryFile,
   });
 
+  // Fetch library file details to get sliced_for_model
+  const { data: libraryFileDetails } = useQuery({
+    queryKey: ['library-file', libraryFileId],
+    queryFn: () => api.getLibraryFile(libraryFileId!),
+    enabled: isLibraryFile && !!libraryFileId,
+  });
+
   // Get sliced_for_model from archive or library file
-  const slicedForModel = archiveDetails?.sliced_for_model || null;
+  const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null;
 
   // Fetch plates for archives
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({

+ 58 - 4
frontend/src/components/VirtualPrinterSettings.tsx

@@ -19,8 +19,9 @@ export function VirtualPrinterSettings() {
   const [localMode, setLocalMode] = useState<LocalMode>('immediate');
   const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
   const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
+  const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState('');
   const [showAccessCode, setShowAccessCode] = useState(false);
-  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | null>(null);
+  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | 'remoteInterface' | null>(null);
 
   // Fetch current settings
   const { data: settings, isLoading } = useQuery({
@@ -41,6 +42,13 @@ export function VirtualPrinterSettings() {
     queryFn: api.getPrinters,
   });
 
+  // Fetch network interfaces for SSDP proxy (only in proxy mode)
+  const { data: networkInterfaces } = useQuery({
+    queryKey: ['network-interfaces'],
+    queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),
+    enabled: localMode === 'proxy',
+  });
+
   // Initialize local state from settings
   useEffect(() => {
     if (settings) {
@@ -53,12 +61,13 @@ export function VirtualPrinterSettings() {
       setLocalMode(mode);
       setLocalModel(settings.model);
       setLocalTargetPrinterId(settings.target_printer_id);
+      setLocalRemoteInterfaceIp(settings.remote_interface_ip || '');
     }
   }, [settings]);
 
   // Update mutation
   const updateMutation = useMutation({
-    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number }) =>
+    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number; remote_interface_ip?: string }) =>
       virtualPrinterApi.updateSettings(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
@@ -148,6 +157,12 @@ export function VirtualPrinterSettings() {
     updateMutation.mutate({ model });
   };
 
+  const handleRemoteInterfaceChange = (ip: string) => {
+    setLocalRemoteInterfaceIp(ip);
+    setPendingAction('remoteInterface');
+    updateMutation.mutate({ remote_interface_ip: ip });
+  };
+
   if (isLoading) {
     return (
       <Card>
@@ -350,6 +365,45 @@ export function VirtualPrinterSettings() {
             </div>
           )}
 
+          {/* Remote Interface - only for proxy mode (SSDP proxy) */}
+          {localMode === 'proxy' && (
+            <div className="py-3 border-t border-bambu-dark-tertiary">
+              <div className="text-white font-medium mb-2">{t('virtualPrinter.remoteInterface.title')}</div>
+              <div className="text-sm text-bambu-gray mb-3">
+                {localRemoteInterfaceIp ? (
+                  <span className="flex items-center gap-1 text-green-400">
+                    <Check className="w-4 h-4" />
+                    {t('virtualPrinter.remoteInterface.configured')}
+                  </span>
+                ) : (
+                  <span className="flex items-center gap-1 text-bambu-gray">
+                    <Info className="w-4 h-4" />
+                    {t('virtualPrinter.remoteInterface.optional')}
+                  </span>
+                )}
+              </div>
+              <div className="relative">
+                <select
+                  value={localRemoteInterfaceIp}
+                  onChange={(e) => handleRemoteInterfaceChange(e.target.value)}
+                  disabled={pendingAction === 'remoteInterface'}
+                  className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10"
+                >
+                  <option value="">{t('virtualPrinter.remoteInterface.placeholder')}</option>
+                  {networkInterfaces?.map((iface) => (
+                    <option key={iface.ip} value={iface.ip}>
+                      {iface.name} ({iface.ip}) - {iface.subnet}
+                    </option>
+                  ))}
+                </select>
+                <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+              </div>
+              <p className="text-xs text-bambu-gray mt-2">
+                {t('virtualPrinter.remoteInterface.hint')}
+              </p>
+            </div>
+          )}
+
           {/* Mode */}
           <div className="py-3 border-t border-bambu-dark-tertiary">
             <div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
@@ -504,11 +558,11 @@ export function VirtualPrinterSettings() {
                   </div>
                   <div>
                     <div className="text-bambu-gray">{t('virtualPrinter.status.ftpConnections')}</div>
-                    <div className="text-white">{status.proxy.ftp_connections}</div>
+                    <div className="text-white">{status.proxy.ftp_connections ?? 0}</div>
                   </div>
                   <div>
                     <div className="text-bambu-gray">{t('virtualPrinter.status.mqttConnections')}</div>
-                    <div className="text-white">{status.proxy.mqtt_connections}</div>
+                    <div className="text-white">{status.proxy.mqtt_connections ?? 0}</div>
                   </div>
                 </div>
               ) : (

+ 136 - 45
frontend/src/hooks/useFilamentMapping.ts

@@ -32,6 +32,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
           isExternal: false,
           label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
           globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+          trayInfoIdx: tray.tray_info_idx || '',
         });
       }
     });
@@ -50,6 +51,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
       isExternal: true,
       label: 'External',
       globalTrayId: 254,
+      trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
     });
   }
 
@@ -60,6 +62,14 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
  * Compute AMS mapping for a printer given filament requirements and printer status.
  * This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
  *
+ * Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
+ *
+ * The tray_info_idx is a filament type identifier stored in the 3MF file when the user
+ * slices (e.g., "GFA00" for generic PLA, "P4d64437" for custom presets). If the same
+ * tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays
+ * have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color
+ * matching among those trays.
+ *
  * @param filamentReqs - Required filaments from the 3MF file
  * @param printerStatus - Current printer status with AMS information
  * @returns AMS mapping array or undefined if no mapping needed
@@ -77,30 +87,66 @@ export function computeAmsMapping(
   const usedTrayIds = new Set<number>();
 
   const comparisons = filamentReqs.filaments.map((req) => {
-    // Auto-match: Find a loaded filament that matches by TYPE
-    // Priority: exact color match > similar color match > type-only match
-    const exactMatch = loadedFilaments.find(
-      (f) =>
-        !usedTrayIds.has(f.globalTrayId) &&
-        f.type?.toUpperCase() === req.type?.toUpperCase() &&
-        normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-    );
-    const similarMatch =
-      !exactMatch &&
-      loadedFilaments.find(
+    const reqTrayInfoIdx = req.tray_info_idx || '';
+
+    // Get available trays (not already used)
+    const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+
+    let idxMatch: LoadedFilament | undefined;
+    let exactMatch: LoadedFilament | undefined;
+    let similarMatch: LoadedFilament | undefined;
+    let typeOnlyMatch: LoadedFilament | undefined;
+
+    // Check if tray_info_idx is unique among available trays
+    if (reqTrayInfoIdx) {
+      const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
+      if (idxMatches.length === 1) {
+        // Unique tray_info_idx - use it as definitive match
+        idxMatch = idxMatches[0];
+      } else if (idxMatches.length > 1) {
+        // Multiple trays with same tray_info_idx - use color matching among them
+        exactMatch = idxMatches.find(
+          (f) =>
+            f.type?.toUpperCase() === req.type?.toUpperCase() &&
+            normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+        );
+        if (!exactMatch) {
+          similarMatch = idxMatches.find(
+            (f) =>
+              f.type?.toUpperCase() === req.type?.toUpperCase() &&
+              colorsAreSimilar(f.color, req.color)
+          );
+        }
+        if (!exactMatch && !similarMatch) {
+          typeOnlyMatch = idxMatches.find(
+            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+          );
+        }
+      }
+    }
+
+    // If no idx match, do standard type/color matching on all available trays
+    if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
+      exactMatch = available.find(
         (f) =>
-          !usedTrayIds.has(f.globalTrayId) &&
           f.type?.toUpperCase() === req.type?.toUpperCase() &&
-          colorsAreSimilar(f.color, req.color)
-      );
-    const typeOnlyMatch =
-      !exactMatch &&
-      !similarMatch &&
-      loadedFilaments.find(
-        (f) =>
-          !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+          normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
       );
-    const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+      if (!exactMatch) {
+        similarMatch = available.find(
+          (f) =>
+            f.type?.toUpperCase() === req.type?.toUpperCase() &&
+            colorsAreSimilar(f.color, req.color)
+        );
+      }
+      if (!exactMatch && !similarMatch) {
+        typeOnlyMatch = available.find(
+          (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+        );
+      }
+    }
+
+    const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
 
     // Mark this tray as used so it won't be assigned to another slot
     if (loaded) {
@@ -143,6 +189,8 @@ export interface LoadedFilament {
   isExternal: boolean;
   label: string;
   globalTrayId: number;
+  /** Unique spool identifier (e.g., "GFA00", "P4d64437") */
+  trayInfoIdx?: string;
 }
 
 /**
@@ -153,6 +201,8 @@ export interface FilamentRequirement {
   type: string;
   color: string;
   used_grams: number;
+  /** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */
+  tray_info_idx?: string;
 }
 
 /**
@@ -215,6 +265,7 @@ export function useLoadedFilaments(
             isExternal: false,
             label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
             globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+            trayInfoIdx: tray.tray_info_idx || '',
           });
         }
       });
@@ -233,6 +284,7 @@ export function useLoadedFilaments(
         isExternal: true,
         label: 'External',
         globalTrayId: 254,
+        trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
       });
     }
 
@@ -297,31 +349,69 @@ export function useFilamentMapping(
         }
       }
 
-      // Auto-match: Find a loaded filament that matches by TYPE
-      // Priority: exact color match > similar color match > type-only match
+      // Auto-match: Find a loaded filament
+      // Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
       // IMPORTANT: Exclude trays that are already assigned (manually or auto)
-      const exactMatch = loadedFilaments.find(
-        (f) =>
-          !usedTrayIds.has(f.globalTrayId) &&
-          f.type?.toUpperCase() === req.type?.toUpperCase() &&
-          normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-      );
-      const similarMatch =
-        !exactMatch &&
-        loadedFilaments.find(
+      const reqTrayInfoIdx = req.tray_info_idx || '';
+
+      // Get available trays (not already used)
+      const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+
+      let idxMatch: LoadedFilament | undefined;
+      let exactMatch: LoadedFilament | undefined;
+      let similarMatch: LoadedFilament | undefined;
+      let typeOnlyMatch: LoadedFilament | undefined;
+
+      // Check if tray_info_idx is unique among available trays
+      if (reqTrayInfoIdx) {
+        const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
+        if (idxMatches.length === 1) {
+          // Unique tray_info_idx - use it as definitive match
+          idxMatch = idxMatches[0];
+        } else if (idxMatches.length > 1) {
+          // Multiple trays with same tray_info_idx - use color matching among them
+          exactMatch = idxMatches.find(
+            (f) =>
+              f.type?.toUpperCase() === req.type?.toUpperCase() &&
+              normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+          );
+          if (!exactMatch) {
+            similarMatch = idxMatches.find(
+              (f) =>
+                f.type?.toUpperCase() === req.type?.toUpperCase() &&
+                colorsAreSimilar(f.color, req.color)
+            );
+          }
+          if (!exactMatch && !similarMatch) {
+            typeOnlyMatch = idxMatches.find(
+              (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+            );
+          }
+        }
+      }
+
+      // If no idx match, do standard type/color matching on all available trays
+      if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
+        exactMatch = available.find(
           (f) =>
-            !usedTrayIds.has(f.globalTrayId) &&
             f.type?.toUpperCase() === req.type?.toUpperCase() &&
-            colorsAreSimilar(f.color, req.color)
-        );
-      const typeOnlyMatch =
-        !exactMatch &&
-        !similarMatch &&
-        loadedFilaments.find(
-          (f) =>
-            !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+            normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
         );
-      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+        if (!exactMatch) {
+          similarMatch = available.find(
+            (f) =>
+              f.type?.toUpperCase() === req.type?.toUpperCase() &&
+              colorsAreSimilar(f.color, req.color)
+          );
+        }
+        if (!exactMatch && !similarMatch) {
+          typeOnlyMatch = available.find(
+            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+          );
+        }
+      }
+
+      const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
 
       // Mark this tray as used so it won't be assigned to another slot
       if (loaded) {
@@ -330,11 +420,12 @@ export function useFilamentMapping(
 
       const hasFilament = !!loaded;
       const typeMatch = hasFilament;
-      const colorMatch = !!exactMatch || !!similarMatch;
+      // idxMatch is always considered a color match (same spool = same color)
+      const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;
 
-      // Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
+      // Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)
       let status: FilamentStatus;
-      if (exactMatch || similarMatch) {
+      if (idxMatch || exactMatch || similarMatch) {
         status = 'match';
       } else if (typeOnlyMatch) {
         status = 'type_only';

+ 19 - 7
frontend/src/i18n/locales/de.ts

@@ -578,7 +578,7 @@ export default {
       object: '{{count}} Objekt',
       objects: '{{count}} Objekte',
       slicedFor: 'Geslict für {{model}}',
-      uploadedBy: 'Hochgeladen von {{name}}',
+      uploadedBy: 'Hochgeladen von',
       noPermissionReprint: 'Sie haben keine Berechtigung, erneut zu drucken',
       noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',
       noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
@@ -687,6 +687,7 @@ export default {
       pending: 'Ausstehend',
       waiting: 'Wartend',
       printing: 'Druckt',
+      paused: 'Pausiert',
       completed: 'Abgeschlossen',
       failed: 'Fehlgeschlagen',
       skipped: 'Übersprungen',
@@ -1837,6 +1838,8 @@ export default {
     zipMayContainStl: 'ZIP-Dateien können STL-Dateien enthalten. Vorschaubilder können während der Extraktion generiert werden.',
     thumbnailsCanBeGenerated: 'Vorschaubilder können für STL-Dateien generiert werden. Große Modelle benötigen möglicherweise mehr Zeit.',
     generateThumbnailsForStl: 'Vorschaubilder für STL-Dateien generieren',
+    threemfDetected: '3MF-Dateien erkannt',
+    threemfExtractionInfo: 'Druckermodell, Material, Farbe und Druckeinstellungen werden automatisch aus 3MF-Dateien extrahiert.',
     willBeExtracted: 'Wird extrahiert',
     filesExtracted: '{{count}} Dateien extrahiert',
     uploadComplete: 'Upload abgeschlossen: {{succeeded}} erfolgreich',
@@ -1846,6 +1849,7 @@ export default {
     linkTo: 'Verknüpfen mit...',
     linkToProjectOrArchive: 'Mit Projekt oder Archiv verknüpfen',
     addToQueue: 'Zur Warteschlange',
+    schedulePrint: 'Planen',
     generateThumbnail: 'Vorschaubild generieren',
     generateThumbnails: 'Vorschaubilder generieren',
     generateThumbnailsForMissing: 'Vorschaubilder für STL-Dateien ohne Vorschau generieren',
@@ -1881,7 +1885,7 @@ export default {
     noMatchingFilesDescription: 'Keine Dateien entsprechen Ihren aktuellen Such- oder Filterkriterien.',
     clearFilters: 'Filter zurücksetzen',
     printedCount: '{{count}}x gedruckt',
-    uploadedBy: 'Hochgeladen von {{name}}',
+    uploadedBy: 'Hochgeladen von',
     deleteFolder: 'Ordner löschen',
     deleteFile: 'Datei löschen',
     deleteFilesCount: '{{count}} Dateien löschen',
@@ -2477,6 +2481,13 @@ export default {
       hint: 'Wähle den Drucker aus, an den der Slicer-Datenverkehr weitergeleitet werden soll. Der Drucker muss im LAN-Modus sein.',
       noPrinters: 'Keine Drucker konfiguriert. Füge zuerst einen Drucker hinzu, um den Proxy-Modus zu verwenden.',
     },
+    remoteInterface: {
+      title: 'Slicer-Netzwerkschnittstelle',
+      configured: 'SSDP-Proxy aktiviert',
+      optional: 'Optional - für SSDP-Erkennung über Netzwerke hinweg',
+      placeholder: 'Schnittstelle für Slicer-Netzwerk auswählen...',
+      hint: 'Wähle die Netzwerkschnittstelle, die mit dem Slicer verbunden ist. Ermöglicht automatische Druckererkennung in Bambu Studio.',
+    },
     mode: {
       title: 'Modus',
       archive: 'Archivieren',
@@ -2502,11 +2513,12 @@ export default {
       step4: 'Der "Bambuddy"-Drucker sollte in der Erkennungsliste erscheinen',
       step5: 'Verbinde mit dem von dir gesetzten Zugangscode',
       step6: 'Wenn du zu Bambuddy "druckst", wird die 3MF-Datei stattdessen archiviert',
-      proxyStep1: 'Setze die Zieldrucker-IP (muss in deinem LAN sein)',
-      proxyStep2: 'Konfiguriere Portweiterleitung zu Bambuddy (Ports 9990, 8883)',
-      proxyStep3: 'Füge in deinem Slicer manuell einen Netzwerkdrucker hinzu',
-      proxyStep4: 'Gib Bambuddys externe Adresse und den Zugangscode des Druckers ein',
-      proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird an den echten Drucker weitergeleitet',
+      proxyStep1: 'Wähle den Zieldrucker (muss im LAN-Modus sein)',
+      proxyStep2: 'Bei Netzwerkübergreifend: Wähle die Slicer-Netzwerkschnittstelle',
+      proxyStep3: 'Aktiviere den Proxy - Drucker erscheint per SSDP in der Slicer-Erkennung',
+      proxyStep4: 'Verbinde mit dem Zugangscode des Druckers',
+      proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird über Bambuddy weitergeleitet',
+      proxyStep6: 'Kamera-Streaming erfordert NAT/IP-Weiterleitung (siehe Dokumentation)',
     },
     status: {
       title: 'Status-Details',

+ 19 - 7
frontend/src/i18n/locales/en.ts

@@ -578,7 +578,7 @@ export default {
       object: '{{count}} object',
       objects: '{{count}} objects',
       slicedFor: 'Sliced for {{model}}',
-      uploadedBy: 'Uploaded by {{name}}',
+      uploadedBy: 'Uploaded By',
       noPermissionReprint: 'You do not have permission to reprint',
       noPermissionEdit: 'You do not have permission to edit archives',
       noPermissionDelete: 'You do not have permission to delete archives',
@@ -687,6 +687,7 @@ export default {
       pending: 'Pending',
       waiting: 'Waiting',
       printing: 'Printing',
+      paused: 'Paused',
       completed: 'Completed',
       failed: 'Failed',
       skipped: 'Skipped',
@@ -1837,6 +1838,8 @@ export default {
     zipMayContainStl: 'ZIP files may contain STL files. Thumbnails can be generated during extraction.',
     thumbnailsCanBeGenerated: 'Thumbnails can be generated for STL files. Large models may take longer to process.',
     generateThumbnailsForStl: 'Generate thumbnails for STL files',
+    threemfDetected: '3MF files detected',
+    threemfExtractionInfo: 'Printer model, material, color, and print settings will be automatically extracted from 3MF files.',
     willBeExtracted: 'Will be extracted',
     filesExtracted: '{{count}} files extracted',
     uploadComplete: 'Upload complete: {{succeeded}} succeeded',
@@ -1846,6 +1849,7 @@ export default {
     linkTo: 'Link to...',
     linkToProjectOrArchive: 'Link to project or archive',
     addToQueue: 'Add to Queue',
+    schedulePrint: 'Schedule',
     generateThumbnail: 'Generate Thumbnail',
     generateThumbnails: 'Generate Thumbnails',
     generateThumbnailsForMissing: 'Generate thumbnails for STL files missing them',
@@ -1881,7 +1885,7 @@ export default {
     noMatchingFilesDescription: 'No files match your current search or filter criteria.',
     clearFilters: 'Clear filters',
     printedCount: 'Printed {{count}}x',
-    uploadedBy: 'Uploaded by {{name}}',
+    uploadedBy: 'Uploaded By',
     deleteFolder: 'Delete Folder',
     deleteFile: 'Delete File',
     deleteFilesCount: 'Delete {{count}} Files',
@@ -2477,6 +2481,13 @@ export default {
       hint: 'Select the printer to proxy slicer traffic to. The printer must be in LAN mode.',
       noPrinters: 'No printers configured. Add a printer first to use proxy mode.',
     },
+    remoteInterface: {
+      title: 'Slicer Network Interface',
+      configured: 'SSDP proxy enabled',
+      optional: 'Optional - for SSDP discovery across networks',
+      placeholder: 'Select interface for slicer network...',
+      hint: 'Select the network interface connected to the slicer. Enables automatic printer discovery in Bambu Studio.',
+    },
     mode: {
       title: 'Mode',
       archive: 'Archive',
@@ -2502,11 +2513,12 @@ export default {
       step4: 'The "Bambuddy" printer should appear in the discovery list',
       step5: 'Connect using the access code you set',
       step6: 'When you "print" to Bambuddy, the 3MF file is archived instead',
-      proxyStep1: 'Set the target printer IP (must be on your LAN)',
-      proxyStep2: 'Configure port forwarding to Bambuddy (ports 9990, 8883)',
-      proxyStep3: 'In your slicer, manually add a network printer',
-      proxyStep4: "Enter Bambuddy's external address and printer's access code",
-      proxyStep5: 'Print as normal - traffic is relayed to the real printer',
+      proxyStep1: 'Select the target printer (must be in LAN mode)',
+      proxyStep2: 'For cross-network: select the slicer network interface',
+      proxyStep3: 'Enable the proxy - printer appears in slicer discovery via SSDP',
+      proxyStep4: 'Connect using the printer\'s access code',
+      proxyStep5: 'Print as normal - traffic is relayed through Bambuddy',
+      proxyStep6: 'Camera streaming requires NAT/IP forwarding (see docs)',
     },
     status: {
       title: 'Status Details',

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

@@ -549,6 +549,7 @@ export default {
     status: {
       pending: '待機中',
       printing: '印刷中',
+      paused: '一時停止',
       completed: '完了',
       failed: '失敗',
       skipped: 'スキップ',
@@ -1473,6 +1474,35 @@ export default {
       model: 'モデル',
       serialNumber: 'シリアルナンバー',
       pendingFiles: '保留中のファイル',
+      modeProxy: 'プロキシ',
+      modeProxyDesc: '実際のプリンターに中継',
+      descriptionProxy: 'スライサーのトラフィックを実際のプリンターに中継するプロキシを有効にし、任意のネットワーク経由でリモート印刷を可能にします。',
+      proxyingTo: '{{name}}にプロキシ中',
+      notActive: '非アクティブ',
+      targetPrinterTitle: 'ターゲットプリンター',
+      targetPrinterConfigured: 'プロキシターゲット設定済み',
+      targetPrinterNotConfigured: 'ターゲットプリンター未選択 - プロキシモードに必要です',
+      targetPrinterPlaceholder: 'プリンターを選択...',
+      targetPrinterHint: 'スライサーのトラフィックを中継するプリンターを選択します。プリンターはLANモードである必要があります。',
+      targetPrinterNoPrinters: 'プリンターが設定されていません。プロキシモードを使用するには、まずプリンターを追加してください。',
+      remoteInterfaceTitle: 'スライサーネットワークインターフェース',
+      remoteInterfaceConfigured: 'SSDPプロキシ有効',
+      remoteInterfaceOptional: 'オプション - ネットワーク間のSSDPディスカバリー用',
+      remoteInterfacePlaceholder: 'スライサーネットワークのインターフェースを選択...',
+      remoteInterfaceHint: 'スライサーに接続されているネットワークインターフェースを選択します。Bambu Studioでの自動プリンター検出を有効にします。',
+      targetPrinterRequired: '先にターゲットプリンターを選択してください',
+      ftpPort: 'FTPポート',
+      mqttPort: 'MQTTポート',
+      ftpConnections: 'FTP接続数',
+      mqttConnections: 'MQTT接続数',
+      targetPrinterStatus: 'ターゲットプリンター',
+      howItWorksProxy: '仕組み(プロキシモード)',
+      proxyStep1: 'ターゲットプリンターを選択(LANモードである必要があります)',
+      proxyStep2: 'クロスネットワーク時:スライサーネットワークインターフェースを選択',
+      proxyStep3: 'プロキシを有効化 - プリンターがSSDPでスライサー検出に表示されます',
+      proxyStep4: 'プリンターのアクセスコードで接続',
+      proxyStep5: '通常通り印刷 - トラフィックはBambuddyを経由して中継されます',
+      proxyStep6: 'カメラストリーミングにはNAT/IP転送が必要です(ドキュメント参照)',
     },
     enterNewCodeToChange: '新しいコードを入力して変更',
     enter8CharCode: '8文字のコードを入力',
@@ -1885,6 +1915,7 @@ export default {
     linkTo: 'リンク先...',
     printedCount: '{{count}}回印刷済み',
     addToQueue: 'キューに追加',
+    schedulePrint: '印刷をスケジュール',
     adding: '追加中...',
     folderCreated: 'フォルダを作成しました',
     folderDeleted: 'フォルダを削除しました',

+ 2 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -2533,7 +2533,7 @@ export function ArchivesPage() {
         </div>
       )}
 
-      <div className="flex items-center justify-between mb-8">
+      <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
         <div>
           <div className="flex items-center gap-3">
             <h1 className="text-2xl font-bold text-white">Archives</h1>
@@ -2553,7 +2553,7 @@ export function ArchivesPage() {
             {filteredArchives?.length || 0} of {archives?.length || 0} prints
           </p>
         </div>
-        <div className="flex items-center gap-3">
+        <div className="flex items-center gap-2 sm:gap-3 flex-wrap">
           {/* Export dropdown */}
           <div className="relative">
             <Button

+ 142 - 54
frontend/src/pages/FileManagerPage.tsx

@@ -37,6 +37,7 @@ import {
   Pencil,
   Play,
   Image,
+  User,
   Box,
 } from 'lucide-react';
 import { api } from '../api/client';
@@ -431,6 +432,7 @@ interface UploadFile {
   status: 'pending' | 'uploading' | 'success' | 'error';
   error?: string;
   isZip?: boolean;
+  is3mf?: boolean;
   extractedCount?: number;
 }
 
@@ -471,6 +473,7 @@ function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProp
       file,
       status: 'pending',
       isZip: file.name.toLowerCase().endsWith('.zip'),
+      is3mf: file.name.toLowerCase().endsWith('.3mf'),
     }));
     setFiles((prev) => [...prev, ...uploadFiles]);
   };
@@ -481,12 +484,14 @@ function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProp
 
   const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
   const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
+  const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');
 
   const handleUpload = async () => {
     if (files.length === 0) return;
 
     setIsUploading(true);
 
+    // Handle all files with library upload (ZIP and regular files including .3mf)
     for (let i = 0; i < files.length; i++) {
       if (files[i].status !== 'pending') continue;
 
@@ -511,7 +516,7 @@ function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProp
             )
           );
         } else {
-          // Regular file upload
+          // Regular file upload (STL, .3mf, etc.) - .3mf files automatically get metadata extracted
           await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails);
           setFiles((prev) =>
             prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
@@ -611,6 +616,21 @@ function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProp
             </div>
           )}
 
+          {/* 3MF File Info - Advanced Extraction */}
+          {has3mfFiles && (
+            <div className="p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <Printer className="w-5 h-5 text-purple-400 mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-purple-300 font-medium">{t('fileManager.threemfDetected')}</p>
+                  <p className="text-xs text-purple-300/70 mt-1">
+                    {t('fileManager.threemfExtractionInfo')}
+                  </p>
+                </div>
+              </div>
+            </div>
+          )}
+
           {/* STL Thumbnail Options - show for STL files or ZIP files (which may contain STLs) */}
           {(hasStlFiles || hasZipFiles) && (
             <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
@@ -895,15 +915,16 @@ interface FileCardProps {
   thumbnailVersion?: number;
   hasPermission: (permission: Permission) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
+  authEnabled: boolean;
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onPreview3d, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onPreview3d, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
     <div
-      className={`group relative bg-bambu-card rounded-lg border transition-all cursor-pointer overflow-hidden ${
+      className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer overflow-hidden ${
         isSelected
           ? 'border-bambu-green ring-1 ring-bambu-green'
           : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
@@ -946,14 +967,21 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
             </span>
           )}
         </div>
+        {file.sliced_for_model && (
+          <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
+            <Printer className="w-3 h-3" />
+            {file.sliced_for_model}
+          </div>
+        )}
         {file.print_count > 0 && (
           <div className="mt-1 text-xs text-bambu-green">
             {t('fileManager.printedCount', { count: file.print_count })}
           </div>
         )}
-        {file.created_by_username && (
-          <div className="mt-1 text-xs text-bambu-gray">
-            {t('fileManager.uploadedBy', { name: file.created_by_username })}
+        {authEnabled && file.created_by_username && (
+          <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
+            <User className="w-3 h-3" />
+            {file.created_by_username}
           </div>
         )}
       </div>
@@ -993,7 +1021,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
                 >
                   <Clock className="w-3.5 h-3.5" />
-                  {t('fileManager.addToQueue')}
+                  {t('fileManager.schedulePrint')}
                 </button>
               )}
               {onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
@@ -1078,7 +1106,7 @@ export function FileManagerPage() {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const { hasPermission, hasAnyPermission, canModify } = useAuth();
+  const { hasPermission, hasAnyPermission, canModify, authEnabled } = useAuth();
   const [searchParams] = useSearchParams();
 
   // Read folder ID from URL query parameter
@@ -1095,6 +1123,7 @@ export function FileManagerPage() {
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
+  const [scheduleFile, setScheduleFile] = useState<LibraryFileListItem | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
@@ -1154,6 +1183,7 @@ export function FileManagerPage() {
   // Filter and sort state (persist sort preferences to localStorage)
   const [searchQuery, setSearchQuery] = useState('');
   const [filterType, setFilterType] = useState<string>('all');
+  const [filterUsername, setFilterUsername] = useState('');
   const [sortField, setSortField] = useState<SortField>(() => {
     const saved = localStorage.getItem('library-sort-field');
     return (saved as SortField) || 'name';
@@ -1195,6 +1225,12 @@ export function FileManagerPage() {
     queryFn: () => api.getLibraryStats(),
   });
 
+  // Get users for the username filter autocomplete
+  const { data: users } = useQuery({
+    queryKey: ['users'],
+    queryFn: () => api.getUsers(),
+  });
+
   // Get unique file types for filter dropdown
   const fileTypes = useMemo(() => {
     if (!files) return [];
@@ -1223,6 +1259,14 @@ export function FileManagerPage() {
       result = result.filter((f) => f.file_type === filterType);
     }
 
+    // Apply username filter
+    if (filterUsername.trim()) {
+      const query = filterUsername.toLowerCase();
+      result = result.filter(
+        (f) => f.created_by_username && f.created_by_username.toLowerCase().includes(query)
+      );
+    }
+
     // Apply sorting
     result.sort((a, b) => {
       let comparison = 0;
@@ -1247,7 +1291,7 @@ export function FileManagerPage() {
     });
 
     return result;
-  }, [files, searchQuery, filterType, sortField, sortDirection]);
+  }, [files, searchQuery, filterType, filterUsername, sortField, sortDirection]);
 
   // Check if disk space is low
   const isDiskSpaceLow = useMemo(() => {
@@ -1345,31 +1389,6 @@ export function FileManagerPage() {
     onError: (error: Error) => showToast(error.message, 'error'),
   });
 
-  const addToQueueMutation = useMutation({
-    mutationFn: (fileIds: number[]) => api.addLibraryFilesToQueue(fileIds),
-    onSuccess: (result) => {
-      queryClient.invalidateQueries({ queryKey: ['library-files'] });
-      queryClient.invalidateQueries({ queryKey: ['queue'] });
-      queryClient.invalidateQueries({ queryKey: ['archives'] }); // Archives are created when adding to queue
-      setSelectedFiles([]);
-
-      if (result.added.length > 0 && result.errors.length === 0) {
-        showToast(
-          t('fileManager.toast.addedToQueue', { count: result.added.length }),
-          'success'
-        );
-      } else if (result.added.length > 0 && result.errors.length > 0) {
-        showToast(
-          t('fileManager.toast.addedToQueuePartial', { added: result.added.length, failed: result.errors.length }),
-          'success'
-        );
-      } else {
-        showToast(t('fileManager.toast.failedToAddToQueue', { error: result.errors[0]?.error || 'Unknown error' }), 'error');
-      }
-    },
-    onError: (error: Error) => showToast(error.message, 'error'),
-  });
-
   const renameFileMutation = useMutation({
     mutationFn: ({ id, filename }: { id: number; filename: string }) =>
       api.updateLibraryFile(id, { filename }),
@@ -1529,7 +1548,7 @@ export function FileManagerPage() {
             <button
               onClick={() => handleViewModeChange('grid')}
               className={`p-1.5 rounded transition-colors ${
-                viewMode === 'grid' ? 'bg-bambu-card text-white' : 'text-bambu-gray hover:text-white'
+                viewMode === 'grid' ? 'bg-bambu-dark-secondary text-white' : 'text-bambu-gray hover:text-white'
               }`}
               title={t('fileManager.gridView')}
             >
@@ -1538,7 +1557,7 @@ export function FileManagerPage() {
             <button
               onClick={() => handleViewModeChange('list')}
               className={`p-1.5 rounded transition-colors ${
-                viewMode === 'list' ? 'bg-bambu-card text-white' : 'text-bambu-gray hover:text-white'
+                viewMode === 'list' ? 'bg-bambu-dark-secondary text-white' : 'text-bambu-gray hover:text-white'
               }`}
               title={t('fileManager.listView')}
             >
@@ -1593,7 +1612,7 @@ export function FileManagerPage() {
 
       {/* Stats bar */}
       {stats && (
-        <div className="flex flex-wrap items-center gap-3 sm:gap-6 mb-6 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
+        <div className="flex flex-wrap items-center gap-3 sm:gap-6 mb-6 p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
           <div className="flex items-center gap-2 text-sm">
             <File className="w-4 h-4 text-bambu-green" />
             <span className="text-bambu-gray">{t('fileManager.files')}:</span>
@@ -1625,7 +1644,7 @@ export function FileManagerPage() {
           <select
             value={selectedFolderId ?? ''}
             onChange={(e) => setSelectedFolderId(e.target.value ? parseInt(e.target.value, 10) : null)}
-            className="w-full bg-bambu-card border border-bambu-dark-tertiary rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-bambu-green"
+            className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-bambu-green"
           >
             <option value="">📁 {t('fileManager.allFiles')}</option>
             {folders && (() => {
@@ -1652,7 +1671,7 @@ export function FileManagerPage() {
         {/* Folder sidebar - resizable, hidden on mobile */}
         <div
           ref={sidebarRef}
-          className="hidden lg:flex flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex-col relative"
+          className="hidden lg:flex flex-shrink-0 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-hidden flex-col relative"
           style={{ width: `${sidebarWidth}px` }}
         >
           {/* Resize handle - drag to resize, double-click to reset */}
@@ -1731,7 +1750,7 @@ export function FileManagerPage() {
         <div className="flex-1 flex flex-col min-w-0 min-h-0">
           {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
           {files && files.length > 0 && (
-            <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static">
+            <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static">
               {/* Search */}
               <div className="relative w-full sm:w-auto sm:flex-1 sm:max-w-xs">
                 <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
@@ -1761,6 +1780,34 @@ export function FileManagerPage() {
                 </select>
               </div>
 
+              {/* Username filter with autocomplete - only show when auth is enabled */}
+              {authEnabled && (
+                <div className="relative">
+                  <input
+                    type="text"
+                    placeholder={t('fileManager.filterByUser', { defaultValue: 'Filter by user' })}
+                    value={filterUsername}
+                    onChange={(e) => setFilterUsername(e.target.value)}
+                    list="usernames-list"
+                    className={`w-32 sm:w-40 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green ${filterUsername ? 'pr-7' : ''}`}
+                    style={filterUsername ? { WebkitAppearance: 'none', MozAppearance: 'textfield' } : undefined}
+                  />
+                  {filterUsername && (
+                    <button
+                      onClick={() => setFilterUsername('')}
+                      className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white z-10"
+                    >
+                      <X className="w-3 h-3" />
+                    </button>
+                  )}
+                  <datalist id="usernames-list">
+                    {users?.map((user) => (
+                      <option key={user.id} value={user.username} />
+                    ))}
+                  </datalist>
+                </div>
+              )}
+
               {/* Sort */}
               <div className="flex items-center gap-2">
                 <select
@@ -1796,7 +1843,7 @@ export function FileManagerPage() {
               </div>
 
               {/* Results count */}
-              {(searchQuery || filterType !== 'all') && (
+              {(searchQuery || filterType !== 'all' || filterUsername) && (
                 <span className="text-sm text-bambu-gray hidden sm:inline">
                   {t('fileManager.resultsCount', { showing: filteredAndSortedFiles.length, total: files.length })}
                 </span>
@@ -1806,7 +1853,7 @@ export function FileManagerPage() {
 
           {/* Selection toolbar - sticky on mobile below search bar */}
           {filteredAndSortedFiles.length > 0 && (
-            <div className="flex flex-wrap items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary sticky top-[52px] z-10 lg:static">
+            <div className="flex flex-wrap items-center gap-2 mb-4 p-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary sticky top-[52px] z-10 lg:static">
               {/* Select all / Deselect all */}
               {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
                 <Button
@@ -1847,16 +1894,19 @@ export function FileManagerPage() {
                         <span className="hidden sm:inline">{t('common.print')}</span>
                       </Button>
                     )}
-                    {selectedSlicedFiles.length > 0 && (
+                    {selectedSlicedFiles.length === 1 && (
                       <Button
-                        variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
+                        variant="secondary"
                         size="sm"
-                        onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
-                        disabled={addToQueueMutation.isPending || !hasPermission('queue:create')}
+                        // Note: Schedule dialog (PrintModal) is designed for single file at a time
+                        // but supports scheduling to multiple printers. This provides more control
+                        // over scheduling options compared to the previous bulk queue mutation.
+                        onClick={() => setScheduleFile(selectedSlicedFiles[0])}
+                        disabled={!hasPermission('queue:create')}
                         title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
                       >
                         <Clock className="w-4 h-4 sm:mr-1" />
-                        <span className="hidden sm:inline">{addToQueueMutation.isPending ? t('fileManager.adding') : `${t('fileManager.addToQueue')}${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}</span>
+                        <span className="hidden sm:inline">{t('fileManager.schedulePrint')}</span>
                       </Button>
                     )}
                     <Button
@@ -1955,7 +2005,10 @@ export function FileManagerPage() {
                     onSelect={handleFileSelect}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDownload={handleDownload}
-                    onAddToQueue={(id) => addToQueueMutation.mutate([id])}
+                    onAddToQueue={(id) => {
+                      const file = files?.find(f => f.id === id);
+                      if (file) setScheduleFile(file);
+                    }}
                     onPrint={setPrintFile}
                     onPreview3d={setViewerFile}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
@@ -1963,17 +2016,19 @@ export function FileManagerPage() {
                     thumbnailVersion={thumbnailVersions[file.id]}
                     hasPermission={hasPermission}
                     canModify={canModify}
+                    authEnabled={authEnabled}
                   />
                 ))}
               </div>
             </div>
           ) : (
             <div className="flex-1 lg:overflow-y-auto">
-              <div className="bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden">
+              <div className="bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-hidden">
                 {/* List header - hidden on mobile, show simplified on small screens */}
-                <div className="hidden sm:grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium">
+                <div className={`hidden sm:grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_80px]' : 'grid-cols-[auto_1fr_100px_100px_100px_80px]'} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`}>
                   <div className="w-6" />
                   <div>{t('common.name')}</div>
+                  {authEnabled && <div>{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}</div>}
                   <div>{t('common.type')}</div>
                   <div>{t('fileManager.size')}</div>
                   <div>{t('fileManager.prints')}</div>
@@ -1983,7 +2038,7 @@ export function FileManagerPage() {
                 {filteredAndSortedFiles.map((file) => (
                   <div
                     key={file.id}
-                    className={`grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
+                    className={`grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_80px]' : 'grid-cols-[auto_1fr_100px_100px_100px_80px]'} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                     }`}
                     onClick={() => handleFileSelect(file.id)}
@@ -2029,6 +2084,19 @@ export function FileManagerPage() {
                         <div className="text-sm text-white truncate">{file.print_name || file.filename}</div>
                       </div>
                     </div>
+                    {/* Uploaded By - only show when auth is enabled */}
+                    {authEnabled && (
+                      <div className="text-sm text-bambu-gray flex items-center gap-1">
+                        {file.created_by_username ? (
+                          <>
+                            <User className="w-3 h-3" />
+                            <span className="truncate">{file.created_by_username}</span>
+                          </>
+                        ) : (
+                          '-'
+                        )}
+                      </div>
+                    )}
                     {/* Type */}
                     <div>
                       <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
@@ -2061,14 +2129,18 @@ export function FileManagerPage() {
                             <Printer className="w-4 h-4" />
                           </button>
                           <button
-                            onClick={() => hasPermission('queue:create') && addToQueueMutation.mutate([file.id])}
+                            onClick={() => {
+                              if (hasPermission('queue:create')) {
+                                setScheduleFile(file);
+                              }
+                            }}
                             className={`p-1.5 rounded transition-colors ${
                               hasPermission('queue:create')
                                 ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
                                 : 'text-bambu-gray/50 cursor-not-allowed'
                             }`}
-                            title={hasPermission('queue:create') ? t('fileManager.addToQueue') : t('fileManager.noPermissionAddToQueue')}
-                            disabled={addToQueueMutation.isPending || !hasPermission('queue:create')}
+                            title={hasPermission('queue:create') ? t('fileManager.schedulePrint') : t('fileManager.noPermissionAddToQueue')}
+                            disabled={!hasPermission('queue:create')}
                           >
                             <Clock className="w-4 h-4" />
                           </button>
@@ -2243,6 +2315,22 @@ export function FileManagerPage() {
         />
       )}
 
+      {scheduleFile && (
+        <PrintModal
+          mode="add-to-queue"
+          libraryFileId={scheduleFile.id}
+          archiveName={scheduleFile.print_name || scheduleFile.filename}
+          onClose={() => setScheduleFile(null)}
+          onSuccess={() => {
+            setScheduleFile(null);
+            setSelectedFiles([]);
+            queryClient.invalidateQueries({ queryKey: ['library-files'] });
+            queryClient.invalidateQueries({ queryKey: ['queue'] });
+            queryClient.invalidateQueries({ queryKey: ['archives'] });
+          }}
+        />
+      )}
+
       {viewerFile && (
         <ModelViewerModal
           libraryFileId={viewerFile.id}

+ 47 - 3
frontend/src/pages/QueuePage.tsx

@@ -1,6 +1,6 @@
 import { useState, useMemo, useEffect, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Link } from 'react-router-dom';
 import {
   DndContext,
@@ -46,6 +46,7 @@ import {
   CheckSquare,
   Square,
   User,
+  Pause,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
@@ -80,7 +81,7 @@ function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat =
   return formatDateTime(dateString, timeFormat);
 }
 
-function StatusBadge({ status, waitingReason, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; t: (key: string) => string }) {
+function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
   // Special case: pending with waiting_reason shows as "Waiting"
   if (status === 'pending' && waitingReason) {
     return (
@@ -91,6 +92,16 @@ function StatusBadge({ status, waitingReason, t }: { status: PrintQueueItem['sta
     );
   }
 
+  // Special case: printing but printer is paused
+  if (status === 'printing' && (printerState === 'PAUSE' || printerState === 'PAUSED')) {
+    return (
+      <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-yellow-400 bg-yellow-400/10 border-yellow-400/20">
+        <Pause className="w-3.5 h-3.5" />
+        {t('queue.status.paused')}
+      </span>
+    );
+  }
+
   const config = {
     pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
     printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') },
@@ -286,6 +297,7 @@ function SortableQueueItem({
   onToggleSelect,
   hasPermission,
   canModify,
+  printerState,
   t,
 }: {
   item: PrintQueueItem;
@@ -301,6 +313,7 @@ function SortableQueueItem({
   onToggleSelect?: () => void;
   hasPermission: (permission: Permission) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
+  printerState?: string | null;
   t: (key: string, options?: Record<string, unknown>) => string;
 }) {
   const canReorder = hasPermission('queue:reorder');
@@ -500,7 +513,7 @@ function SortableQueueItem({
         </div>
 
         {/* Status badge */}
-        <StatusBadge status={item.status} waitingReason={item.waiting_reason} t={t} />
+        <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
 
         {/* Actions */}
         <div className="flex items-center gap-1">
@@ -825,6 +838,36 @@ export function QueuePage() {
     return items;
   }, [queue, filterLocation, matchesLocationFilter]);
 
+  // Get unique printer IDs from active items to fetch their statuses
+  const activePrinterIds = useMemo(() => {
+    const ids = new Set<number>();
+    activeItems.forEach(item => {
+      if (item.printer_id) ids.add(item.printer_id);
+    });
+    return Array.from(ids);
+  }, [activeItems]);
+
+  // Fetch printer statuses for printers with active jobs
+  const printerStatusQueries = useQueries({
+    queries: activePrinterIds.map(printerId => ({
+      queryKey: ['printerStatus', printerId],
+      queryFn: () => api.getPrinterStatus(printerId),
+      refetchInterval: 5000,
+    })),
+  });
+
+  // Build a map of printer_id -> state for quick lookup
+  const printerStateMap = useMemo(() => {
+    const map: Record<number, string | null> = {};
+    activePrinterIds.forEach((printerId, index) => {
+      const result = printerStatusQueries[index];
+      if (result?.data?.state) {
+        map[printerId] = result.data.state;
+      }
+    });
+    return map;
+  }, [activePrinterIds, printerStatusQueries]);
+
   const historyItems = useMemo(() => {
     let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
     if (filterLocation) {
@@ -1035,6 +1078,7 @@ export function QueuePage() {
                     timeFormat={timeFormat}
                     hasPermission={hasPermission}
                     canModify={canModify}
+                    printerState={item.printer_id ? printerStateMap[item.printer_id] : null}
                     t={t}
                   />
                 ))}

+ 2 - 2
frontend/src/pages/StatsPage.tsx

@@ -672,12 +672,12 @@ export function StatsPage() {
 
   return (
     <div className="p-4 md:p-8">
-      <div className="flex items-center justify-between mb-6">
+      <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
         <div>
           <h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
           <p className="text-bambu-gray">{t('stats.subtitle')}</p>
         </div>
-        <div className="flex items-center gap-2">
+        <div className="flex items-center gap-2 flex-wrap">
           {/* Hidden widgets button - toggles panel in Dashboard */}
           {hiddenCount > 0 && (
             <Button

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


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


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


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


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


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


+ 2 - 2
static/index.html

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

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