Browse Source

Merge branch '0.1.6-final' into feature/173

Merged MQTT smart plug support with latest 0.1.6-final features.
Kept MQTT plug functionality (separate topics, multipliers, etc.)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
maziggy 3 months ago
parent
commit
a965afa6cb
69 changed files with 5655 additions and 1597 deletions
  1. 10 5
      README.md
  2. BIN
      backend/.coverage
  3. 98 0
      backend/app/api/routes/archives.py
  4. 24 18
      backend/app/api/routes/camera.py
  5. 129 0
      backend/app/api/routes/library.py
  6. 8 0
      backend/app/api/routes/notification_templates.py
  7. 8 0
      backend/app/api/routes/notifications.py
  8. 175 7
      backend/app/api/routes/print_queue.py
  9. 24 0
      backend/app/api/routes/settings.py
  10. 115 14
      backend/app/api/routes/smart_plugs.py
  11. 28 0
      backend/app/main.py
  12. 3 0
      backend/app/models/archive.py
  13. 9 0
      backend/app/models/notification.py
  14. 43 0
      backend/app/models/notification_template.py
  15. 9 0
      backend/app/models/print_queue.py
  16. 2 0
      backend/app/schemas/archive.py
  17. 29 0
      backend/app/schemas/library.py
  18. 18 0
      backend/app/schemas/notification.py
  19. 55 0
      backend/app/schemas/notification_template.py
  20. 6 0
      backend/app/schemas/print_queue.py
  21. 6 4
      backend/app/schemas/smart_plug.py
  22. 33 3
      backend/app/services/archive.py
  23. 1 1
      backend/app/services/homeassistant.py
  24. 150 0
      backend/app/services/notification_service.py
  25. 2 0
      backend/app/services/plate_detection.py
  26. 309 43
      backend/app/services/print_scheduler.py
  27. 11 6
      backend/app/services/smart_plug_manager.py
  28. 7 2
      backend/app/services/spoolman.py
  29. 140 0
      backend/app/services/stl_thumbnail.py
  30. 86 0
      backend/app/utils/printer_models.py
  31. 124 0
      backend/tests/integration/test_archives_api.py
  32. 27 5
      backend/tests/integration/test_camera_api.py
  33. 236 0
      backend/tests/integration/test_library_api.py
  34. 204 0
      backend/tests/unit/services/test_stl_thumbnail.py
  35. 0 4
      demo-video/.gitignore
  36. 0 50
      demo-video/README.md
  37. 0 537
      demo-video/package-lock.json
  38. 0 15
      demo-video/package.json
  39. 0 566
      demo-video/record-demo.ts
  40. 48 18
      deploy/bambuddy.service
  41. 1 1
      frontend/eslint.config.js
  42. 1 1
      frontend/src/__tests__/api/githubBackupApi.test.ts
  43. 5 1
      frontend/src/__tests__/components/EditArchiveModal.test.tsx
  44. 88 0
      frontend/src/__tests__/components/NotificationProviderCard.test.tsx
  45. 300 0
      frontend/src/__tests__/components/TagManagementModal.test.tsx
  46. 0 45
      frontend/src/__tests__/components/UploadModal.test.tsx
  47. 198 2
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  48. 91 9
      frontend/src/api/client.ts
  49. 33 14
      frontend/src/components/EditArchiveModal.tsx
  50. 82 0
      frontend/src/components/NotificationProviderCard.tsx
  51. 133 8
      frontend/src/components/PrintModal/PrinterSelector.tsx
  52. 144 42
      frontend/src/components/PrintModal/index.tsx
  53. 17 0
      frontend/src/components/PrintModal/types.ts
  54. 294 0
      frontend/src/components/TagManagementModal.tsx
  55. 6 27
      frontend/src/components/UploadModal.tsx
  56. 36 4
      frontend/src/pages/ArchivesPage.tsx
  57. 243 79
      frontend/src/pages/FileManagerPage.tsx
  58. 41 4
      frontend/src/pages/PrintersPage.tsx
  59. 26 4
      frontend/src/pages/QueuePage.tsx
  60. 73 56
      frontend/src/pages/SettingsPage.tsx
  61. 234 0
      install/README.md
  62. 541 0
      install/docker-install.sh
  63. 883 0
      install/install.sh
  64. 5 0
      requirements.txt
  65. 0 0
      static/assets/index-BJQC4Kk-.js
  66. 0 0
      static/assets/index-C1mIxrzF.css
  67. 0 0
      static/assets/index-Cs7zD_Fu.css
  68. 2 2
      static/index.html
  69. 1 0
      test_frontend.sh

+ 10 - 5
README.md

@@ -54,6 +54,7 @@
 - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
 - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
 - Archive comparison (side-by-side diff)
+- Tag management (rename/delete across all archives)
 
 
 ### ๐Ÿ“Š Monitoring & Control
 ### ๐Ÿ“Š Monitoring & Control
 - Real-time printer status via WebSocket
 - Real-time printer status via WebSocket
@@ -75,6 +76,8 @@
 ### โฐ Scheduling & Automation
 ### โฐ Scheduling & Automation
 - Print queue with drag-and-drop
 - Print queue with drag-and-drop
 - Multi-printer selection (send to multiple printers at once)
 - Multi-printer selection (send to multiple printers at once)
+- Model-based queue assignment (send to "any X1C" for load balancing)
+- Filament validation (only assign to printers with required filaments)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
 - Queue Only mode (stage without auto-start)
@@ -86,7 +89,8 @@
 - Auto power-off after cooldown
 - Auto power-off after cooldown
 
 
 ### ๐Ÿ“ File Manager (Library)
 ### ๐Ÿ“ File Manager (Library)
-- Upload and organize sliced files (3MF, gcode)
+- Upload and organize sliced files (3MF, gcode, STL)
+- **STL thumbnail generation** - Auto-generate previews for STL files on upload or batch generate for existing files
 - ZIP file extraction with folder structure preservation
 - ZIP file extraction with folder structure preservation
 - Option to create folder from ZIP filename
 - Option to create folder from ZIP filename
 - Folder structure with drag-and-drop
 - Folder structure with drag-and-drop
@@ -117,6 +121,7 @@
 - Print finish photo URL in notifications
 - Print finish photo URL in notifications
 - HMS error alerts (AMS, nozzle, etc.)
 - HMS error alerts (AMS, nozzle, etc.)
 - Build plate detection alerts
 - Build plate detection alerts
+- Queue events (waiting, skipped, failed)
 
 
 ### ๐Ÿ”ง Integrations
 ### ๐Ÿ”ง Integrations
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync
@@ -507,10 +512,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
 
 
 ---
 ---
 
 
-If you like Bambuddy and want to support it, you can <a href="https://ko-fi.com/maziggy" target=_blank>buy Martin a coffee</a>.
-
----
-
 ## ๐Ÿ“„ License
 ## ๐Ÿ“„ License
 
 
 MIT License โ€” see [LICENSE](LICENSE) for details.
 MIT License โ€” see [LICENSE](LICENSE) for details.
@@ -525,6 +526,10 @@ MIT License โ€” see [LICENSE](LICENSE) for details.
 
 
 ---
 ---
 
 
+If you like Bambuddy and want to support it, you can <a href="https://ko-fi.com/maziggy" target=_blank>buy Martin a coffee</a>.
+
+---
+
 <p align="center">
 <p align="center">
   Made with โค๏ธ for the 3D printing community
   Made with โค๏ธ for the 3D printing community
   <br><br>
   <br><br>

BIN
backend/.coverage


+ 98 - 0
backend/app/api/routes/archives.py

@@ -78,6 +78,7 @@ def archive_to_response(
         "nozzle_diameter": archive.nozzle_diameter,
         "nozzle_diameter": archive.nozzle_diameter,
         "bed_temperature": archive.bed_temperature,
         "bed_temperature": archive.bed_temperature,
         "nozzle_temperature": archive.nozzle_temperature,
         "nozzle_temperature": archive.nozzle_temperature,
+        "sliced_for_model": archive.sliced_for_model,
         "status": archive.status,
         "status": archive.status,
         "started_at": archive.started_at,
         "started_at": archive.started_at,
         "completed_at": archive.completed_at,
         "completed_at": archive.completed_at,
@@ -563,6 +564,103 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     )
     )
 
 
 
 
+@router.get("/tags")
+async def get_all_tags(db: AsyncSession = Depends(get_db)):
+    """List all unique tags with usage counts.
+
+    Returns a list of tags sorted by count (descending), then by name.
+    """
+    # Query all archives with non-null tags
+    result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None)))
+    all_tags_rows = result.all()
+
+    # Count occurrences of each tag
+    tag_counts: dict[str, int] = {}
+    for (tags_str,) in all_tags_rows:
+        if tags_str:
+            for tag in tags_str.split(","):
+                tag = tag.strip()
+                if tag:
+                    tag_counts[tag] = tag_counts.get(tag, 0) + 1
+
+    # Convert to list and sort by count (desc), then name (asc)
+    tags_list = [{"name": name, "count": count} for name, count in tag_counts.items()]
+    tags_list.sort(key=lambda x: (-x["count"], x["name"].lower()))
+
+    return tags_list
+
+
+@router.put("/tags/{tag_name}")
+async def rename_tag(
+    tag_name: str,
+    request: Request,
+    db: AsyncSession = Depends(get_db),
+):
+    """Rename a tag across all archives.
+
+    Request body should contain {"new_name": "new tag name"}.
+    Returns the count of affected archives.
+    """
+    body = await request.json()
+    new_name = body.get("new_name", "").strip()
+
+    if not new_name:
+        raise HTTPException(400, "new_name is required")
+
+    if new_name == tag_name:
+        return {"affected": 0}
+
+    # Find all archives containing the old tag
+    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
+    archives = list(result.scalars().all())
+
+    affected = 0
+    for archive in archives:
+        if not archive.tags:
+            continue
+        tags = [t.strip() for t in archive.tags.split(",")]
+        if tag_name in tags:
+            # Replace old tag with new tag
+            new_tags = [new_name if t == tag_name else t for t in tags]
+            # Remove duplicates while preserving order
+            seen = set()
+            unique_tags = []
+            for t in new_tags:
+                if t not in seen:
+                    seen.add(t)
+                    unique_tags.append(t)
+            archive.tags = ", ".join(unique_tags)
+            affected += 1
+
+    await db.commit()
+    return {"affected": affected}
+
+
+@router.delete("/tags/{tag_name}")
+async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
+    """Delete a tag from all archives.
+
+    Returns the count of affected archives.
+    """
+    # Find all archives containing the tag
+    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
+    archives = list(result.scalars().all())
+
+    affected = 0
+    for archive in archives:
+        if not archive.tags:
+            continue
+        tags = [t.strip() for t in archive.tags.split(",")]
+        if tag_name in tags:
+            # Remove the tag
+            new_tags = [t for t in tags if t != tag_name]
+            archive.tags = ", ".join(new_tags) if new_tags else None
+            affected += 1
+
+    await db.commit()
+    return {"affected": affected}
+
+
 @router.get("/{archive_id}", response_model=ArchiveResponse)
 @router.get("/{archive_id}", response_model=ArchiveResponse)
 async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
 async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific archive."""
     """Get a specific archive."""

+ 24 - 18
backend/app/api/routes/camera.py

@@ -713,14 +713,15 @@ async def check_plate_empty(
     )
     )
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
+    # Check printer exists first (before OpenCV check)
+    printer = await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
     if not is_plate_detection_available():
         raise HTTPException(
         raise HTTPException(
             status_code=503,
             status_code=503,
             detail="Plate detection not available. Install opencv-python-headless to enable.",
             detail="Plate detection not available. Install opencv-python-headless to enable.",
         )
         )
 
 
-    printer = await get_printer_or_404(printer_id, db)
-
     # Check chamber light status
     # Check chamber light status
     light_warning = False
     light_warning = False
     state = printer_manager.get_status(printer_id)
     state = printer_manager.get_status(printer_id)
@@ -818,14 +819,15 @@ async def calibrate_plate_detection(
     )
     )
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
+    # Check printer exists first (before OpenCV check)
+    printer = await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
     if not is_plate_detection_available():
         raise HTTPException(
         raise HTTPException(
             status_code=503,
             status_code=503,
             detail="Plate detection not available. Install opencv-python-headless to enable.",
             detail="Plate detection not available. Install opencv-python-headless to enable.",
         )
         )
 
 
-    printer = await get_printer_or_404(printer_id, db)
-
     # Check chamber light - warn but don't block
     # Check chamber light - warn but don't block
     state = printer_manager.get_status(printer_id)
     state = printer_manager.get_status(printer_id)
     light_warning = state and not state.chamber_light
     light_warning = state and not state.chamber_light
@@ -869,15 +871,15 @@ async def delete_plate_calibration(
         is_plate_detection_available,
         is_plate_detection_available,
     )
     )
 
 
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
     if not is_plate_detection_available():
         raise HTTPException(
         raise HTTPException(
             status_code=503,
             status_code=503,
             detail="Plate detection not available. Install opencv-python-headless to enable.",
             detail="Plate detection not available. Install opencv-python-headless to enable.",
         )
         )
 
 
-    # Verify printer exists
-    await get_printer_or_404(printer_id, db)
-
     deleted = delete_calibration(printer_id, plate_type)
     deleted = delete_calibration(printer_id, plate_type)
     plate_msg = f" for '{plate_type}'" if plate_type else ""
     plate_msg = f" for '{plate_type}'" if plate_type else ""
 
 
@@ -909,6 +911,9 @@ async def get_plate_detection_status(
     )
     )
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
     if not is_plate_detection_available():
         return {
         return {
             "available": False,
             "available": False,
@@ -918,9 +923,6 @@ async def get_plate_detection_status(
             "message": "OpenCV not installed",
             "message": "OpenCV not installed",
         }
         }
 
 
-    # Verify printer exists
-    await get_printer_or_404(printer_id, db)
-
     # Get chamber light status
     # Get chamber light status
     state = printer_manager.get_status(printer_id)
     state = printer_manager.get_status(printer_id)
     chamber_light = state.chamber_light if state else False
     chamber_light = state.chamber_light if state else False
@@ -942,11 +944,12 @@ async def get_plate_references(
     """
     """
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
 
 
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
     if not is_plate_detection_available():
         raise HTTPException(503, "Plate detection not available")
         raise HTTPException(503, "Plate detection not available")
 
 
-    await get_printer_or_404(printer_id, db)
-
     detector = PlateDetector()
     detector = PlateDetector()
     references = detector.get_references(printer_id)
     references = detector.get_references(printer_id)
 
 
@@ -973,11 +976,12 @@ async def get_reference_thumbnail(
 
 
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
 
 
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
     if not is_plate_detection_available():
         raise HTTPException(503, "Plate detection not available")
         raise HTTPException(503, "Plate detection not available")
 
 
-    await get_printer_or_404(printer_id, db)
-
     detector = PlateDetector()
     detector = PlateDetector()
     thumbnail = detector.get_reference_thumbnail(printer_id, index)
     thumbnail = detector.get_reference_thumbnail(printer_id, index)
 
 
@@ -997,11 +1001,12 @@ async def update_reference_label(
     """Update the label for a calibration reference."""
     """Update the label for a calibration reference."""
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
 
 
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
     if not is_plate_detection_available():
         raise HTTPException(503, "Plate detection not available")
         raise HTTPException(503, "Plate detection not available")
 
 
-    await get_printer_or_404(printer_id, db)
-
     detector = PlateDetector()
     detector = PlateDetector()
     success = detector.update_reference_label(printer_id, index, label)
     success = detector.update_reference_label(printer_id, index, label)
 
 
@@ -1020,11 +1025,12 @@ async def delete_reference(
     """Delete a specific calibration reference."""
     """Delete a specific calibration reference."""
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
 
 
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
     if not is_plate_detection_available():
         raise HTTPException(503, "Plate detection not available")
         raise HTTPException(503, "Plate detection not available")
 
 
-    await get_printer_or_404(printer_id, db)
-
     detector = PlateDetector()
     detector = PlateDetector()
     success = detector.delete_reference(printer_id, index)
     success = detector.delete_reference(printer_id, index)
 
 

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

@@ -25,6 +25,9 @@ from backend.app.schemas.library import (
     AddToQueueRequest,
     AddToQueueRequest,
     AddToQueueResponse,
     AddToQueueResponse,
     AddToQueueResult,
     AddToQueueResult,
+    BatchThumbnailRequest,
+    BatchThumbnailResponse,
+    BatchThumbnailResult,
     BulkDeleteRequest,
     BulkDeleteRequest,
     BulkDeleteResponse,
     BulkDeleteResponse,
     FileDuplicate,
     FileDuplicate,
@@ -43,6 +46,7 @@ from backend.app.schemas.library import (
     ZipExtractResult,
     ZipExtractResult,
 )
 )
 from backend.app.services.archive import ArchiveService, ThreeMFParser
 from backend.app.services.archive import ArchiveService, ThreeMFParser
+from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -621,6 +625,7 @@ async def list_files(
 async def upload_file(
 async def upload_file(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     folder_id: int | None = None,
     folder_id: int | None = None,
+    generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Upload a file to the library."""
     """Upload a file to the library."""
@@ -712,6 +717,11 @@ async def upload_file(
             # For image files, create a thumbnail from the image itself
             # For image files, create a thumbnail from the image itself
             thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
             thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
 
 
+        elif ext == ".stl":
+            # Generate STL thumbnail if enabled
+            if generate_stl_thumbnails:
+                thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+
         # Create database entry
         # Create database entry
         library_file = LibraryFile(
         library_file = LibraryFile(
             folder_id=folder_id,
             folder_id=folder_id,
@@ -749,6 +759,7 @@ async def extract_zip_file(
     folder_id: int | None = Query(default=None),
     folder_id: int | None = Query(default=None),
     preserve_structure: bool = Query(default=True),
     preserve_structure: bool = Query(default=True),
     create_folder_from_zip: bool = Query(default=False),
     create_folder_from_zip: bool = Query(default=False),
+    generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Upload and extract a ZIP file to the library.
     """Upload and extract a ZIP file to the library.
@@ -758,6 +769,7 @@ async def extract_zip_file(
         folder_id: Target folder ID (None = root)
         folder_id: Target folder ID (None = root)
         preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat
         preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat
         create_folder_from_zip: If True, create a folder named after the ZIP file and extract into it
         create_folder_from_zip: If True, create a folder named after the ZIP file and extract into it
+        generate_stl_thumbnails: If True, generate thumbnails for STL files
     """
     """
     import tempfile
     import tempfile
     import zipfile
     import zipfile
@@ -941,6 +953,11 @@ async def extract_zip_file(
                     elif ext.lower() in IMAGE_EXTENSIONS:
                     elif ext.lower() in IMAGE_EXTENSIONS:
                         thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
                         thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
 
 
+                    elif ext == ".stl":
+                        # Generate STL thumbnail if enabled
+                        if generate_stl_thumbnails:
+                            thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+
                     # Create database entry
                     # Create database entry
                     library_file = LibraryFile(
                     library_file = LibraryFile(
                         folder_id=target_folder_id,
                         folder_id=target_folder_id,
@@ -994,6 +1011,118 @@ async def extract_zip_file(
             pass
             pass
 
 
 
 
+# ============ STL Thumbnail Batch Generation ============
+
+
+@router.post("/generate-stl-thumbnails", response_model=BatchThumbnailResponse)
+async def batch_generate_stl_thumbnails(
+    request: BatchThumbnailRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """Generate thumbnails for STL files in batch.
+
+    Can generate thumbnails for:
+    - Specific file IDs (file_ids)
+    - All STL files in a folder (folder_id)
+    - All STL files missing thumbnails (all_missing=True)
+    """
+    thumbnails_dir = get_library_thumbnails_dir()
+    results: list[BatchThumbnailResult] = []
+
+    # Build query based on request
+    query = select(LibraryFile).where(LibraryFile.file_type == "stl")
+
+    if request.file_ids:
+        # Specific files
+        query = query.where(LibraryFile.id.in_(request.file_ids))
+    elif request.folder_id is not None:
+        # All STL files in a specific folder
+        query = query.where(LibraryFile.folder_id == request.folder_id)
+        if not request.all_missing:
+            # If not specifically asking for missing thumbnails, get all
+            pass
+        else:
+            query = query.where(LibraryFile.thumbnail_path.is_(None))
+    elif request.all_missing:
+        # All STL files without thumbnails
+        query = query.where(LibraryFile.thumbnail_path.is_(None))
+    else:
+        # No criteria specified - return empty
+        return BatchThumbnailResponse(
+            processed=0,
+            succeeded=0,
+            failed=0,
+            results=[],
+        )
+
+    result = await db.execute(query)
+    stl_files = result.scalars().all()
+
+    succeeded = 0
+    failed = 0
+
+    for stl_file in stl_files:
+        file_path = Path(stl_file.file_path)
+
+        if not file_path.exists():
+            results.append(
+                BatchThumbnailResult(
+                    file_id=stl_file.id,
+                    filename=stl_file.filename,
+                    success=False,
+                    error="File not found on disk",
+                )
+            )
+            failed += 1
+            continue
+
+        try:
+            thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+
+            if thumbnail_path:
+                # Update database
+                stl_file.thumbnail_path = thumbnail_path
+                await db.flush()
+                results.append(
+                    BatchThumbnailResult(
+                        file_id=stl_file.id,
+                        filename=stl_file.filename,
+                        success=True,
+                    )
+                )
+                succeeded += 1
+            else:
+                results.append(
+                    BatchThumbnailResult(
+                        file_id=stl_file.id,
+                        filename=stl_file.filename,
+                        success=False,
+                        error="Thumbnail generation failed",
+                    )
+                )
+                failed += 1
+        except Exception as e:
+            logger.error(f"Failed to generate thumbnail for {stl_file.filename}: {e}")
+            results.append(
+                BatchThumbnailResult(
+                    file_id=stl_file.id,
+                    filename=stl_file.filename,
+                    success=False,
+                    error=str(e),
+                )
+            )
+            failed += 1
+
+    await db.commit()
+
+    return BatchThumbnailResponse(
+        processed=len(stl_files),
+        succeeded=succeeded,
+        failed=failed,
+        results=results,
+    )
+
+
 # ============ Queue Operations ============
 # ============ Queue Operations ============
 # NOTE: These routes must be defined BEFORE /files/{file_id} to avoid path parameter conflicts
 # NOTE: These routes must be defined BEFORE /files/{file_id} to avoid path parameter conflicts
 
 

+ 8 - 0
backend/app/api/routes/notification_templates.py

@@ -32,6 +32,14 @@ EVENT_NAMES = {
     "filament_low": "Filament Low",
     "filament_low": "Filament Low",
     "maintenance_due": "Maintenance Due",
     "maintenance_due": "Maintenance Due",
     "test": "Test Notification",
     "test": "Test Notification",
+    # Queue notifications
+    "queue_job_added": "Queue Job Added",
+    "queue_job_assigned": "Queue Job Assigned",
+    "queue_job_started": "Queue Job Started",
+    "queue_job_waiting": "Queue Job Waiting",
+    "queue_job_skipped": "Queue Job Skipped",
+    "queue_job_failed": "Queue Job Failed",
+    "queue_completed": "Queue Completed",
 }
 }
 
 
 
 

+ 8 - 0
backend/app/api/routes/notifications.py

@@ -53,6 +53,14 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
         "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
         # Build plate detection
         # Build plate detection
         "on_plate_not_empty": provider.on_plate_not_empty,
         "on_plate_not_empty": provider.on_plate_not_empty,
+        # Print queue events
+        "on_queue_job_added": provider.on_queue_job_added,
+        "on_queue_job_assigned": provider.on_queue_job_assigned,
+        "on_queue_job_started": provider.on_queue_job_started,
+        "on_queue_job_waiting": provider.on_queue_job_waiting,
+        "on_queue_job_skipped": provider.on_queue_job_skipped,
+        "on_queue_job_failed": provider.on_queue_job_failed,
+        "on_queue_completed": provider.on_queue_completed,
         # Quiet hours
         # Quiet hours
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_start": provider.quiet_hours_start,
         "quiet_hours_start": provider.quiet_hours_start,

+ 175 - 7
backend/app/api/routes/print_queue.py

@@ -2,13 +2,17 @@
 
 
 import json
 import json
 import logging
 import logging
+import xml.etree.ElementTree as ET
+import zipfile
 from datetime import datetime
 from datetime import datetime
+from pathlib import Path
 
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
+from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
@@ -22,12 +26,75 @@ from backend.app.schemas.print_queue import (
     PrintQueueItemUpdate,
     PrintQueueItemUpdate,
     PrintQueueReorder,
     PrintQueueReorder,
 )
 )
+from backend.app.services.notification_service import notification_service
+from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 router = APIRouter(prefix="/queue", tags=["queue"])
 router = APIRouter(prefix="/queue", tags=["queue"])
 
 
 
 
+def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = None) -> list[str]:
+    """Extract unique filament types from a 3MF file.
+
+    Args:
+        file_path: Path to the 3MF file
+        plate_id: Optional plate index to filter for (for multi-plate files)
+
+    Returns:
+        List of unique filament types (e.g., ["PLA", "PETG"])
+    """
+    types: set[str] = set()
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return []
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+
+            if plate_id is not None:
+                # Find the plate element with matching index
+                for plate_elem in root.findall(".//plate"):
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "index":
+                            try:
+                                plate_index = int(meta.get("value", "0"))
+                            except ValueError:
+                                pass
+                            break
+
+                    if plate_index == plate_id:
+                        for filament_elem in plate_elem.findall("filament"):
+                            filament_type = filament_elem.get("type", "")
+                            used_g = filament_elem.get("used_g", "0")
+                            try:
+                                used_grams = float(used_g)
+                            except (ValueError, TypeError):
+                                used_grams = 0
+                            if used_grams > 0 and filament_type:
+                                types.add(filament_type)
+                        break
+            else:
+                # No plate_id specified - extract all filaments with used_g > 0
+                for filament_elem in root.findall(".//filament"):
+                    filament_type = filament_elem.get("type", "")
+                    used_g = filament_elem.get("used_g", "0")
+                    try:
+                        used_grams = float(used_g)
+                    except (ValueError, TypeError):
+                        used_grams = 0
+                    if used_grams > 0 and filament_type:
+                        types.add(filament_type)
+
+    except Exception as e:
+        logger.warning(f"Failed to extract filament types from {file_path}: {e}")
+
+    return sorted(types)
+
+
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer/library_file info to response."""
     """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
     # Parse ams_mapping from JSON string BEFORE model_validate
@@ -38,10 +105,21 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
         except json.JSONDecodeError:
             ams_mapping_parsed = None
             ams_mapping_parsed = None
 
 
+    # Parse required_filament_types from JSON string
+    required_filament_types_parsed = None
+    if item.required_filament_types:
+        try:
+            required_filament_types_parsed = json.loads(item.required_filament_types)
+        except json.JSONDecodeError:
+            required_filament_types_parsed = None
+
     # Create response with parsed ams_mapping
     # Create response with parsed ams_mapping
     item_dict = {
     item_dict = {
         "id": item.id,
         "id": item.id,
         "printer_id": item.printer_id,
         "printer_id": item.printer_id,
+        "target_model": item.target_model,
+        "required_filament_types": required_filament_types_parsed,
+        "waiting_reason": item.waiting_reason,
         "archive_id": item.archive_id,
         "archive_id": item.archive_id,
         "library_file_id": item.library_file_id,
         "library_file_id": item.library_file_id,
         "position": item.position,
         "position": item.position,
@@ -120,29 +198,71 @@ async def add_to_queue(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Add an item to the print queue."""
     """Add an item to the print queue."""
+    # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
+    target_model_norm = None
+    if data.target_model:
+        target_model_norm = (
+            normalize_printer_model(data.target_model)
+            or normalize_printer_model_id(data.target_model)
+            or data.target_model
+        )
+
     # Validate that either archive_id or library_file_id is provided
     # Validate that either archive_id or library_file_id is provided
     if not data.archive_id and not data.library_file_id:
     if not data.archive_id and not data.library_file_id:
         raise HTTPException(400, "Either archive_id or library_file_id must be provided")
         raise HTTPException(400, "Either archive_id or library_file_id must be provided")
 
 
+    # Cannot specify both printer_id and target_model
+    if data.printer_id and target_model_norm:
+        raise HTTPException(400, "Cannot specify both printer_id and target_model")
+
     # Validate printer exists (if assigned)
     # Validate printer exists (if assigned)
     if data.printer_id is not None:
     if data.printer_id is not None:
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
 
 
-    # Validate archive exists (if provided)
+    # Validate target_model has active printers
+    if target_model_norm:
+        result = await db.execute(
+            select(Printer).where(Printer.model == target_model_norm).where(Printer.is_active == True)  # noqa: E712
+        )
+        if not result.scalars().first():
+            raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+
+    # Validate archive exists (if provided) and get it for filament extraction
+    archive = None
     if data.archive_id:
     if data.archive_id:
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
-        if not result.scalar_one_or_none():
+        archive = result.scalar_one_or_none()
+        if not archive:
             raise HTTPException(400, "Archive not found")
             raise HTTPException(400, "Archive not found")
 
 
-    # Validate library file exists (if provided)
+    # Validate library file exists (if provided) and get it for filament extraction
+    library_file = None
     if data.library_file_id:
     if data.library_file_id:
         result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id))
         result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id))
-        if not result.scalar_one_or_none():
+        library_file = result.scalar_one_or_none()
+        if not library_file:
             raise HTTPException(400, "Library file not found")
             raise HTTPException(400, "Library file not found")
 
 
-    # Get next position for this printer (or for unassigned items)
+    # Extract filament types for model-based assignment (used by scheduler for validation)
+    required_filament_types = None
+    if target_model_norm:
+        # Get file path from archive or library file
+        file_path = None
+        if archive:
+            file_path = settings.base_dir / archive.file_path
+        elif library_file:
+            lib_path = Path(library_file.file_path)
+            file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+
+        if file_path and file_path.exists():
+            filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
+            if filament_types:
+                required_filament_types = json.dumps(filament_types)
+                logger.info(f"Extracted filament types for model-based queue: {filament_types}")
+
+    # Get next position for this printer (or for unassigned/model-based items)
     if data.printer_id is not None:
     if data.printer_id is not None:
         result = await db.execute(
         result = await db.execute(
             select(func.max(PrintQueueItem.position))
             select(func.max(PrintQueueItem.position))
@@ -150,7 +270,7 @@ async def add_to_queue(
             .where(PrintQueueItem.status == "pending")
             .where(PrintQueueItem.status == "pending")
         )
         )
     else:
     else:
-        # For unassigned items, get max position across all unassigned
+        # For unassigned/model-based items, get max position across all unassigned
         result = await db.execute(
         result = await db.execute(
             select(func.max(PrintQueueItem.position))
             select(func.max(PrintQueueItem.position))
             .where(PrintQueueItem.printer_id.is_(None))
             .where(PrintQueueItem.printer_id.is_(None))
@@ -160,6 +280,8 @@ async def add_to_queue(
 
 
     item = PrintQueueItem(
     item = PrintQueueItem(
         printer_id=data.printer_id,
         printer_id=data.printer_id,
+        target_model=target_model_norm,
+        required_filament_types=required_filament_types,
         archive_id=data.archive_id,
         archive_id=data.archive_id,
         library_file_id=data.library_file_id,
         library_file_id=data.library_file_id,
         scheduled_time=data.scheduled_time,
         scheduled_time=data.scheduled_time,
@@ -185,7 +307,8 @@ async def add_to_queue(
     await db.refresh(item, ["archive", "printer", "library_file"])
     await db.refresh(item, ["archive", "printer", "library_file"])
 
 
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
-    logger.info(f"Added {source_name} to queue for printer {data.printer_id or 'unassigned'}")
+    target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
+    logger.info(f"Added {source_name} to queue for {target_desc}")
 
 
     # MQTT relay - publish queue job added
     # MQTT relay - publish queue job added
     try:
     try:
@@ -200,6 +323,29 @@ async def add_to_queue(
     except Exception:
     except Exception:
         pass  # Don't fail queue add if MQTT fails
         pass  # Don't fail queue add if MQTT fails
 
 
+    # Send notification for job added
+    try:
+        job_name = (
+            item.archive.filename
+            if item.archive
+            else item.library_file.filename
+            if item.library_file
+            else f"Job #{item.id}"
+        )
+        job_name = job_name.replace(".gcode.3mf", "").replace(".3mf", "")
+        target = (
+            item.printer.name if item.printer else (f"Any {item.target_model}" if target_model_norm else "Unassigned")
+        )
+        await notification_service.on_queue_job_added(
+            job_name=job_name,
+            target=target,
+            db=db,
+            printer_id=item.printer_id,
+            printer_name=item.printer.name if item.printer else None,
+        )
+    except Exception:
+        pass  # Don't fail queue add if notification fails
+
     return _enrich_response(item)
     return _enrich_response(item)
 
 
 
 
@@ -287,12 +433,34 @@ async def update_queue_item(
 
 
     update_data = data.model_dump(exclude_unset=True)
     update_data = data.model_dump(exclude_unset=True)
 
 
+    # Normalize target_model if being updated
+    if "target_model" in update_data and update_data["target_model"]:
+        update_data["target_model"] = (
+            normalize_printer_model(update_data["target_model"])
+            or normalize_printer_model_id(update_data["target_model"])
+            or update_data["target_model"]
+        )
+
+    # Cannot specify both printer_id and target_model
+    new_printer_id = update_data.get("printer_id", item.printer_id)
+    new_target_model = update_data.get("target_model", item.target_model)
+    if new_printer_id and new_target_model:
+        raise HTTPException(400, "Cannot specify both printer_id and target_model")
+
     # Validate new printer_id if being changed (and not None)
     # Validate new printer_id if being changed (and not None)
     if "printer_id" in update_data and update_data["printer_id"] is not None:
     if "printer_id" in update_data and update_data["printer_id"] is not None:
         result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
 
 
+    # Validate target_model has active printers
+    if "target_model" in update_data and update_data["target_model"]:
+        result = await db.execute(
+            select(Printer).where(Printer.model == update_data["target_model"]).where(Printer.is_active == True)  # noqa: E712
+        )
+        if not result.scalars().first():
+            raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
+
     # Serialize ams_mapping to JSON for TEXT column storage
     # Serialize ams_mapping to JSON for TEXT column storage
     if "ams_mapping" in update_data:
     if "ams_mapping" in update_data:
         update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None
         update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None

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

@@ -311,6 +311,13 @@ async def export_backup(
                     "on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False),
                     "on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False),
                     "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False),
                     "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False),
                     "on_plate_not_empty": getattr(p, "on_plate_not_empty", True),
                     "on_plate_not_empty": getattr(p, "on_plate_not_empty", True),
+                    "on_queue_job_added": getattr(p, "on_queue_job_added", False),
+                    "on_queue_job_assigned": getattr(p, "on_queue_job_assigned", False),
+                    "on_queue_job_started": getattr(p, "on_queue_job_started", False),
+                    "on_queue_job_waiting": getattr(p, "on_queue_job_waiting", True),
+                    "on_queue_job_skipped": getattr(p, "on_queue_job_skipped", True),
+                    "on_queue_job_failed": getattr(p, "on_queue_job_failed", True),
+                    "on_queue_completed": getattr(p, "on_queue_completed", False),
                     "quiet_hours_enabled": p.quiet_hours_enabled,
                     "quiet_hours_enabled": p.quiet_hours_enabled,
                     "quiet_hours_start": p.quiet_hours_start,
                     "quiet_hours_start": p.quiet_hours_start,
                     "quiet_hours_end": p.quiet_hours_end,
                     "quiet_hours_end": p.quiet_hours_end,
@@ -391,6 +398,7 @@ async def export_backup(
                     "schedule_on_time": plug.schedule_on_time,
                     "schedule_on_time": plug.schedule_on_time,
                     "schedule_off_time": plug.schedule_off_time,
                     "schedule_off_time": plug.schedule_off_time,
                     "show_in_switchbar": plug.show_in_switchbar,
                     "show_in_switchbar": plug.show_in_switchbar,
+                    "show_on_printer_card": plug.show_on_printer_card,
                 }
                 }
             )
             )
         backup["included"].append("smart_plugs")
         backup["included"].append("smart_plugs")
@@ -1191,6 +1199,13 @@ async def import_backup(
                     existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False)
                     existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False)
                     existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False)
                     existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False)
                     existing.on_plate_not_empty = provider_data.get("on_plate_not_empty", True)
                     existing.on_plate_not_empty = provider_data.get("on_plate_not_empty", True)
+                    existing.on_queue_job_added = provider_data.get("on_queue_job_added", False)
+                    existing.on_queue_job_assigned = provider_data.get("on_queue_job_assigned", False)
+                    existing.on_queue_job_started = provider_data.get("on_queue_job_started", False)
+                    existing.on_queue_job_waiting = provider_data.get("on_queue_job_waiting", True)
+                    existing.on_queue_job_skipped = provider_data.get("on_queue_job_skipped", True)
+                    existing.on_queue_job_failed = provider_data.get("on_queue_job_failed", True)
+                    existing.on_queue_completed = provider_data.get("on_queue_completed", False)
                     existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
                     existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
                     existing.quiet_hours_start = provider_data.get("quiet_hours_start")
                     existing.quiet_hours_start = provider_data.get("quiet_hours_start")
                     existing.quiet_hours_end = provider_data.get("quiet_hours_end")
                     existing.quiet_hours_end = provider_data.get("quiet_hours_end")
@@ -1221,6 +1236,13 @@ async def import_backup(
                     on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False),
                     on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False),
                     on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False),
                     on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False),
                     on_plate_not_empty=provider_data.get("on_plate_not_empty", True),
                     on_plate_not_empty=provider_data.get("on_plate_not_empty", True),
+                    on_queue_job_added=provider_data.get("on_queue_job_added", False),
+                    on_queue_job_assigned=provider_data.get("on_queue_job_assigned", False),
+                    on_queue_job_started=provider_data.get("on_queue_job_started", False),
+                    on_queue_job_waiting=provider_data.get("on_queue_job_waiting", True),
+                    on_queue_job_skipped=provider_data.get("on_queue_job_skipped", True),
+                    on_queue_job_failed=provider_data.get("on_queue_job_failed", True),
+                    on_queue_completed=provider_data.get("on_queue_completed", False),
                     quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
                     quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
                     quiet_hours_start=provider_data.get("quiet_hours_start"),
                     quiet_hours_start=provider_data.get("quiet_hours_start"),
                     quiet_hours_end=provider_data.get("quiet_hours_end"),
                     quiet_hours_end=provider_data.get("quiet_hours_end"),
@@ -1336,6 +1358,7 @@ async def import_backup(
                     existing.schedule_on_time = plug_data.get("schedule_on_time")
                     existing.schedule_on_time = plug_data.get("schedule_on_time")
                     existing.schedule_off_time = plug_data.get("schedule_off_time")
                     existing.schedule_off_time = plug_data.get("schedule_off_time")
                     existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
                     existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
+                    existing.show_on_printer_card = plug_data.get("show_on_printer_card", True)
                     restored["smart_plugs"] += 1
                     restored["smart_plugs"] += 1
                 else:
                 else:
                     skipped["smart_plugs"] += 1
                     skipped["smart_plugs"] += 1
@@ -1380,6 +1403,7 @@ async def import_backup(
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
                     show_in_switchbar=plug_data.get("show_in_switchbar", False),
                     show_in_switchbar=plug_data.get("show_in_switchbar", False),
+                    show_on_printer_card=plug_data.get("show_on_printer_card", True),
                 )
                 )
                 db.add(plug)
                 db.add(plug)
                 restored["smart_plugs"] += 1
                 restored["smart_plugs"] += 1

+ 115 - 14
backend/app/api/routes/smart_plugs.py

@@ -57,9 +57,21 @@ async def create_smart_plug(
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
 
 
         # Check if printer already has a plug assigned
         # Check if printer already has a plug assigned
-        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
-        if result.scalar_one_or_none():
-            raise HTTPException(400, "This printer already has a smart plug assigned")
+        # Scripts can coexist with other plugs (they're for multi-device control, not power on/off)
+        is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.")
+        if not is_script:
+            # For non-script plugs, check there's no other non-script plug assigned
+            result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
+            existing = result.scalar_one_or_none()
+            if existing:
+                # Allow if existing plug is a script
+                existing_is_script = (
+                    existing.plug_type == "homeassistant"
+                    and existing.ha_entity_id
+                    and existing.ha_entity_id.startswith("script.")
+                )
+                if not existing_is_script:
+                    raise HTTPException(400, "This printer already has a smart plug assigned")
 
 
     # For MQTT plugs, ensure MQTT broker is configured and service is connected
     # For MQTT plugs, ensure MQTT broker is configured and service is connected
     if data.plug_type == "mqtt":
     if data.plug_type == "mqtt":
@@ -131,12 +143,48 @@ async def create_smart_plug(
 
 
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
 async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
-    """Get the smart plug assigned to a printer."""
+    """Get the main smart plug assigned to a printer.
+
+    When multiple plugs are assigned (e.g., a regular plug + script),
+    returns the main (non-script) plug for power control.
+    """
     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-    plug = result.scalar_one_or_none()
-    if not plug:
+    plugs = result.scalars().all()
+
+    if not plugs:
         return None
         return None
-    return plug
+
+    # If multiple plugs, prefer the non-script one (main power plug)
+    for plug in plugs:
+        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+        if not is_script:
+            return plug
+
+    # All are scripts, return the first one
+    return plugs[0]
+
+
+@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
+async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Get all HA script plugs assigned to a printer.
+
+    Returns only script entities (script.*) for the printer that have
+    show_on_printer_card enabled.
+    Used to display "Run Script" buttons alongside the main power plug.
+    """
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+    plugs = result.scalars().all()
+
+    # Filter to only scripts with show_on_printer_card enabled
+    scripts = [
+        plug
+        for plug in plugs
+        if plug.plug_type == "homeassistant"
+        and plug.ha_entity_id
+        and plug.ha_entity_id.startswith("script.")
+        and plug.show_on_printer_card
+    ]
+    return scripts
 
 
 
 
 # Tasmota Discovery Endpoints
 # Tasmota Discovery Endpoints
@@ -344,14 +392,29 @@ async def update_smart_plug(
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
 
 
         # Check if that printer already has a different plug assigned
         # Check if that printer already has a different plug assigned
-        result = await db.execute(
-            select(SmartPlug).where(
-                SmartPlug.printer_id == new_printer_id,
-                SmartPlug.id != plug_id,
+        # Scripts can coexist with other plugs
+        # Determine if the plug being updated is/will be a script
+        new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id)
+        new_plug_type = update_data.get("plug_type", plug.plug_type)
+        is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.")
+
+        if not is_script:
+            result = await db.execute(
+                select(SmartPlug).where(
+                    SmartPlug.printer_id == new_printer_id,
+                    SmartPlug.id != plug_id,
+                )
             )
             )
-        )
-        if result.scalar_one_or_none():
-            raise HTTPException(400, "This printer already has a smart plug assigned")
+            existing = result.scalar_one_or_none()
+            if existing:
+                # Allow if existing plug is a script
+                existing_is_script = (
+                    existing.plug_type == "homeassistant"
+                    and existing.ha_entity_id
+                    and existing.ha_entity_id.startswith("script.")
+                )
+                if not existing_is_script:
+                    raise HTTPException(400, "This printer already has a smart plug assigned")
 
 
     # Track old MQTT settings for comparison
     # Track old MQTT settings for comparison
     old_plug_type = plug.plug_type
     old_plug_type = plug.plug_type
@@ -508,6 +571,13 @@ async def control_smart_plug(
     plug.last_checked = datetime.utcnow()
     plug.last_checked = datetime.utcnow()
     await db.commit()
     await db.commit()
 
 
+    # Trigger associated scripts if this is a main (non-script) plug
+    is_main_plug = not (
+        plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+    )
+    if is_main_plug and plug.printer_id and expected_state:
+        await trigger_associated_scripts(plug.printer_id, expected_state, db)
+
     # MQTT relay - publish smart plug state change
     # MQTT relay - publish smart plug state change
     if expected_state:
     if expected_state:
         try:
         try:
@@ -533,6 +603,37 @@ async def control_smart_plug(
     return {"success": True, "action": control.action}
     return {"success": True, "action": control.action}
 
 
 
 
+async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession):
+    """Trigger scripts linked to a printer based on main plug state change.
+
+    When the main plug turns ON, triggers scripts with auto_on=True.
+    When the main plug turns OFF, triggers scripts with auto_off=True.
+    """
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+    plugs = result.scalars().all()
+
+    # Find scripts that should be triggered
+    for plug in plugs:
+        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+        if not is_script:
+            continue
+
+        should_trigger = False
+        if plug_state == "ON" and plug.auto_on:
+            should_trigger = True
+            logger.info(f"Auto-triggering script '{plug.name}' on printer power-on")
+        elif plug_state == "OFF" and plug.auto_off:
+            should_trigger = True
+            logger.info(f"Auto-triggering script '{plug.name}' on printer power-off")
+
+        if should_trigger:
+            try:
+                service = await _get_service_for_plug(plug, db)
+                await service.turn_on(plug)  # Scripts are triggered by calling turn_on
+            except Exception as e:
+                logger.error(f"Failed to trigger script '{plug.name}': {e}")
+
+
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
 async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     """Get current plug status from device including energy data."""
     """Get current plug status from device including energy data."""

+ 28 - 0
backend/app/main.py

@@ -2000,6 +2000,34 @@ async def on_print_complete(printer_id: int, data: dict):
                 except Exception:
                 except Exception:
                     pass  # Don't fail if MQTT fails
                     pass  # Don't fail if MQTT fails
 
 
+                # Check if queue is now empty and send notification
+                try:
+                    from sqlalchemy import func
+
+                    # Count remaining pending items
+                    count_result = await db.execute(
+                        select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "pending")
+                    )
+                    pending_count = count_result.scalar() or 0
+
+                    if pending_count == 0:
+                        # Count how many completed today (rough approximation)
+                        today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+                        completed_result = await db.execute(
+                            select(func.count(PrintQueueItem.id)).where(
+                                PrintQueueItem.status.in_(["completed", "failed", "skipped"]),
+                                PrintQueueItem.completed_at >= today_start,
+                            )
+                        )
+                        completed_count = completed_result.scalar() or 1
+
+                        await notification_service.on_queue_completed(
+                            completed_count=completed_count,
+                            db=db,
+                        )
+                except Exception:
+                    pass  # Don't fail if notification fails
+
                 # Handle auto_off_after - power off printer if requested (after cooldown)
                 # Handle auto_off_after - power off printer if requested (after cooldown)
                 if queue_item.auto_off_after:
                 if queue_item.auto_off_after:
                     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
                     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))

+ 3 - 0
backend/app/models/archive.py

@@ -35,6 +35,9 @@ class PrintArchive(Base):
     bed_temperature: Mapped[int | None] = mapped_column(Integer)
     bed_temperature: Mapped[int | None] = mapped_column(Integer)
     nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
     nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
 
 
+    # Printer model this file was sliced for (extracted from 3MF metadata)
+    sliced_for_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
+
     # Print result
     # Print result
     status: Mapped[str] = mapped_column(String(20), default="completed")
     status: Mapped[str] = mapped_column(String(20), default="completed")
     started_at: Mapped[datetime | None] = mapped_column(DateTime)
     started_at: Mapped[datetime | None] = mapped_column(DateTime)

+ 9 - 0
backend/app/models/notification.py

@@ -83,6 +83,15 @@ class NotificationProvider(Base):
     # Event triggers - Build plate detection
     # Event triggers - Build plate detection
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
 
 
+    # Event triggers - Print queue
+    on_queue_job_added = Column(Boolean, default=False)  # Job added to queue
+    on_queue_job_assigned = Column(Boolean, default=False)  # Model-based job assigned to printer
+    on_queue_job_started = Column(Boolean, default=False)  # Queue job started printing
+    on_queue_job_waiting = Column(Boolean, default=True)  # Job waiting for filament
+    on_queue_job_skipped = Column(Boolean, default=True)  # Job skipped (previous print failed)
+    on_queue_job_failed = Column(Boolean, default=True)  # Job failed to start
+    on_queue_completed = Column(Boolean, default=False)  # All pending jobs finished
+
     # Quiet hours (do not disturb)
     # Quiet hours (do not disturb)
     quiet_hours_enabled = Column(Boolean, default=False)
     quiet_hours_enabled = Column(Boolean, default=False)
     quiet_hours_start = Column(String(5), nullable=True)  # HH:MM format, e.g., "22:00"
     quiet_hours_start = Column(String(5), nullable=True)  # HH:MM format, e.g., "22:00"

+ 43 - 0
backend/app/models/notification_template.py

@@ -103,4 +103,47 @@ DEFAULT_TEMPLATES = [
         "title_template": "Bambuddy Test",
         "title_template": "Bambuddy Test",
         "body_template": "This is a test notification. If you see this, notifications are working!",
         "body_template": "This is a test notification. If you see this, notifications are working!",
     },
     },
+    # Queue notifications
+    {
+        "event_type": "queue_job_added",
+        "name": "Queue Job Added",
+        "title_template": "Job Queued",
+        "body_template": "{job_name} added to queue for {target}",
+    },
+    {
+        "event_type": "queue_job_assigned",
+        "name": "Queue Job Assigned",
+        "title_template": "Job Assigned",
+        "body_template": "{job_name} assigned to {printer} (from Any {target_model} queue)",
+    },
+    {
+        "event_type": "queue_job_started",
+        "name": "Queue Job Started",
+        "title_template": "Queue Job Started",
+        "body_template": "{printer}: {job_name}\nEstimated: {estimated_time}",
+    },
+    {
+        "event_type": "queue_job_waiting",
+        "name": "Queue Job Waiting",
+        "title_template": "Job Waiting for Filament",
+        "body_template": "{job_name} waiting for {target_model}\n{waiting_reason}",
+    },
+    {
+        "event_type": "queue_job_skipped",
+        "name": "Queue Job Skipped",
+        "title_template": "Job Skipped",
+        "body_template": "{printer}: {job_name}\nReason: {reason}",
+    },
+    {
+        "event_type": "queue_job_failed",
+        "name": "Queue Job Failed",
+        "title_template": "Job Failed to Start",
+        "body_template": "{printer}: {job_name}\nReason: {reason}",
+    },
+    {
+        "event_type": "queue_completed",
+        "name": "Queue Completed",
+        "title_template": "Queue Complete",
+        "body_template": "All {completed_count} queued jobs have finished",
+    },
 ]
 ]

+ 9 - 0
backend/app/models/print_queue.py

@@ -15,6 +15,15 @@ class PrintQueueItem(Base):
 
 
     # Links
     # Links
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
+    # Target printer model for model-based assignment (mutually exclusive with printer_id)
+    # When set, scheduler assigns to any idle printer of matching model
+    target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
+    # Required filament types for model-based assignment (JSON array, e.g., '["PLA", "PETG"]')
+    # Used by scheduler to validate printer has compatible filaments loaded
+    required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)
+    # Waiting reason - explains why a model-based job hasn't started yet
+    # Set by scheduler when no matching printer is available
+    waiting_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
     # Either archive_id OR library_file_id must be set (archive created at print start from library file)
     # Either archive_id OR library_file_id must be set (archive created at print start from library file)
     archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
     archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
     library_file_id: Mapped[int | None] = mapped_column(
     library_file_id: Mapped[int | None] = mapped_column(

+ 2 - 0
backend/app/schemas/archive.py

@@ -63,6 +63,8 @@ class ArchiveResponse(BaseModel):
     bed_temperature: int | None
     bed_temperature: int | None
     nozzle_temperature: int | None
     nozzle_temperature: int | None
 
 
+    sliced_for_model: str | None = None  # Printer model this file was sliced for
+
     status: str
     status: str
     started_at: datetime | None
     started_at: datetime | None
     completed_at: datetime | None
     completed_at: datetime | None

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

@@ -262,3 +262,32 @@ class ZipExtractResponse(BaseModel):
     folders_created: int
     folders_created: int
     files: list[ZipExtractResult]
     files: list[ZipExtractResult]
     errors: list[ZipExtractError]
     errors: list[ZipExtractError]
+
+
+# ============ STL Thumbnail Generation ============
+
+
+class BatchThumbnailRequest(BaseModel):
+    """Schema for batch STL thumbnail generation request."""
+
+    file_ids: list[int] | None = None
+    folder_id: int | None = None
+    all_missing: bool = False
+
+
+class BatchThumbnailResult(BaseModel):
+    """Result for a single file thumbnail generation."""
+
+    file_id: int
+    filename: str
+    success: bool
+    error: str | None = None
+
+
+class BatchThumbnailResponse(BaseModel):
+    """Schema for batch thumbnail generation response."""
+
+    processed: int
+    succeeded: int
+    failed: int
+    results: list[BatchThumbnailResult]

+ 18 - 0
backend/app/schemas/notification.py

@@ -53,6 +53,15 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - Build plate detection
     # Event triggers - Build plate detection
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
 
 
+    # Event triggers - Print queue
+    on_queue_job_added: bool = Field(default=False, description="Notify when job is added to queue")
+    on_queue_job_assigned: bool = Field(default=False, description="Notify when model-based job is assigned to printer")
+    on_queue_job_started: bool = Field(default=False, description="Notify when queue job starts printing")
+    on_queue_job_waiting: bool = Field(default=True, description="Notify when job is waiting for filament")
+    on_queue_job_skipped: bool = Field(default=True, description="Notify when job is skipped")
+    on_queue_job_failed: bool = Field(default=True, description="Notify when job fails to start")
+    on_queue_completed: bool = Field(default=False, description="Notify when all queue jobs finish")
+
     # Quiet hours
     # Quiet hours
     quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
     quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
     quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
     quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
@@ -120,6 +129,15 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - Build plate detection
     # Event triggers - Build plate detection
     on_plate_not_empty: bool | None = None
     on_plate_not_empty: bool | None = None
 
 
+    # Event triggers - Print queue
+    on_queue_job_added: bool | None = None
+    on_queue_job_assigned: bool | None = None
+    on_queue_job_started: bool | None = None
+    on_queue_job_waiting: bool | None = None
+    on_queue_job_skipped: bool | None = None
+    on_queue_job_failed: bool | None = None
+    on_queue_completed: bool | None = None
+
     # Quiet hours
     # Quiet hours
     quiet_hours_enabled: bool | None = None
     quiet_hours_enabled: bool | None = None
     quiet_hours_start: str | None = None
     quiet_hours_start: str | None = None

+ 55 - 0
backend/app/schemas/notification_template.py

@@ -45,6 +45,14 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
     "test": ["app_name", "timestamp"],
+    # Queue notifications
+    "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
+    "queue_job_assigned": ["job_name", "printer", "target_model", "timestamp", "app_name"],
+    "queue_job_started": ["printer", "job_name", "estimated_time", "timestamp", "app_name"],
+    "queue_job_waiting": ["job_name", "target_model", "waiting_reason", "timestamp", "app_name"],
+    "queue_job_skipped": ["printer", "job_name", "reason", "timestamp", "app_name"],
+    "queue_job_failed": ["printer", "job_name", "reason", "timestamp", "app_name"],
+    "queue_completed": ["completed_count", "timestamp", "app_name"],
 }
 }
 
 
 # Sample data for previewing templates
 # Sample data for previewing templates
@@ -136,6 +144,53 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",
         "timestamp": "2024-01-15 14:30",
     },
     },
+    # Queue notifications
+    "queue_job_added": {
+        "job_name": "Benchy.3mf",
+        "target": "Bambu X1C",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_assigned": {
+        "job_name": "Benchy.3mf",
+        "printer": "Bambu X1C #1",
+        "target_model": "X1C",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_started": {
+        "printer": "Bambu X1C",
+        "job_name": "Benchy.3mf",
+        "estimated_time": "1h 23m",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_waiting": {
+        "job_name": "Benchy.3mf",
+        "target_model": "X1C",
+        "waiting_reason": "Printer1 (needs PLA)",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_skipped": {
+        "printer": "Bambu X1C",
+        "job_name": "Benchy.3mf",
+        "reason": "Previous print failed",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_failed": {
+        "printer": "Bambu X1C",
+        "job_name": "Benchy.3mf",
+        "reason": "Upload failed: connection timeout",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_completed": {
+        "completed_count": "5",
+        "timestamp": "2024-01-15 18:30",
+        "app_name": "Bambuddy",
+    },
 }
 }
 
 
 
 

+ 6 - 0
backend/app/schemas/print_queue.py

@@ -17,6 +17,8 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
 
 
 class PrintQueueItemCreate(BaseModel):
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
     printer_id: int | None = None  # None = unassigned, user assigns later
+    target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
+    required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     # Either archive_id OR library_file_id must be provided
     # Either archive_id OR library_file_id must be provided
     archive_id: int | None = None
     archive_id: int | None = None
     library_file_id: int | None = None
     library_file_id: int | None = None
@@ -40,6 +42,7 @@ class PrintQueueItemCreate(BaseModel):
 
 
 class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemUpdate(BaseModel):
     printer_id: int | None = None
     printer_id: int | None = None
+    target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     position: int | None = None
     position: int | None = None
     scheduled_time: datetime | None = None
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
     require_previous_success: bool | None = None
@@ -59,6 +62,9 @@ class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemResponse(BaseModel):
 class PrintQueueItemResponse(BaseModel):
     id: int
     id: int
     printer_id: int | None  # None = unassigned
     printer_id: int | None  # None = unassigned
+    target_model: str | None = None  # Target printer model for model-based assignment
+    required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
+    waiting_reason: str | None = None  # Why a model-based job hasn't started yet
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     library_file_id: int | None  # For queue items from library files
     library_file_id: int | None  # For queue items from library files
     position: int
     position: int

+ 6 - 4
backend/app/schemas/smart_plug.py

@@ -14,7 +14,7 @@ class SmartPlugBase(BaseModel):
     password: str | None = None
     password: str | None = None
 
 
     # Home Assistant fields (required when plug_type="homeassistant")
     # Home Assistant fields (required when plug_type="homeassistant")
-    ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean)\.[a-z0-9_]+$")
+    ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean|script)\.[a-z0-9_]+$")
     # Home Assistant energy sensor entities (optional, for separate energy sensors)
     # Home Assistant energy sensor entities (optional, for separate energy sensors)
     ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
@@ -59,8 +59,9 @@ class SmartPlugBase(BaseModel):
     schedule_enabled: bool = False
     schedule_enabled: bool = False
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
-    # Switchbar visibility
+    # Visibility options
     show_in_switchbar: bool = False
     show_in_switchbar: bool = False
+    show_on_printer_card: bool = True  # For scripts: show on printer card
 
 
     @model_validator(mode="after")
     @model_validator(mode="after")
     def validate_plug_type_fields(self) -> "SmartPlugBase":
     def validate_plug_type_fields(self) -> "SmartPlugBase":
@@ -127,8 +128,9 @@ class SmartPlugUpdate(BaseModel):
     schedule_enabled: bool | None = None
     schedule_enabled: bool | None = None
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
-    # Switchbar visibility
+    # Visibility options
     show_in_switchbar: bool | None = None
     show_in_switchbar: bool | None = None
+    show_on_printer_card: bool | None = None
 
 
 
 
 class SmartPlugResponse(SmartPlugBase):
 class SmartPlugResponse(SmartPlugBase):
@@ -197,7 +199,7 @@ class HAEntity(BaseModel):
     entity_id: str
     entity_id: str
     friendly_name: str
     friendly_name: str
     state: str | None = None
     state: str | None = None
-    domain: str  # "switch", "light", "input_boolean"
+    domain: str  # "switch", "light", "input_boolean", "script"
 
 
 
 
 class HASensorEntity(BaseModel):
 class HASensorEntity(BaseModel):

+ 33 - 3
backend/app/services/archive.py

@@ -67,6 +67,19 @@ class ThreeMFParser:
                 content = zf.read("Metadata/slice_info.config").decode()
                 content = zf.read("Metadata/slice_info.config").decode()
                 root = ET.fromstring(content)
                 root = ET.fromstring(content)
 
 
+                # Extract printer_model_id from plate metadata
+                # Format: <plate><metadata key="printer_model_id" value="C11" /></plate>
+                for meta in root.findall(".//metadata"):
+                    key = meta.get("key")
+                    value = meta.get("value")
+                    if key == "printer_model_id" and value:
+                        from backend.app.utils.printer_models import normalize_printer_model_id
+
+                        normalized = normalize_printer_model_id(value)
+                        if normalized:
+                            self.metadata["sliced_for_model"] = normalized
+                        break
+
                 # Find the plate element (single-plate exports only have one plate)
                 # Find the plate element (single-plate exports only have one plate)
                 plate = root.find(".//plate")
                 plate = root.find(".//plate")
 
 
@@ -156,7 +169,7 @@ class ThreeMFParser:
             pass
             pass
 
 
     def _parse_gcode_header(self, zf: zipfile.ZipFile):
     def _parse_gcode_header(self, zf: zipfile.ZipFile):
-        """Parse G-code file header for total layer count."""
+        """Parse G-code file header for total layer count and printer model."""
         import re
         import re
 
 
         try:
         try:
@@ -165,15 +178,25 @@ class ThreeMFParser:
             if not gcode_files:
             if not gcode_files:
                 return
                 return
 
 
-            # Read first 2KB of G-code (header contains the layer count)
+            # Read first 4KB of G-code (header contains metadata)
             gcode_path = gcode_files[0]
             gcode_path = gcode_files[0]
             with zf.open(gcode_path) as f:
             with zf.open(gcode_path) as f:
-                header = f.read(2048).decode("utf-8", errors="ignore")
+                header = f.read(4096).decode("utf-8", errors="ignore")
 
 
             # Look for "; total layer number: XX" pattern
             # Look for "; total layer number: XX" pattern
             match = re.search(r";\s*total\s+layer\s+number[:\s]+(\d+)", header, re.IGNORECASE)
             match = re.search(r";\s*total\s+layer\s+number[:\s]+(\d+)", header, re.IGNORECASE)
             if match:
             if match:
                 self.metadata["total_layers"] = int(match.group(1))
                 self.metadata["total_layers"] = int(match.group(1))
+
+            # Look for printer_model in gcode header (fallback if not found in slice_info)
+            # Format: "; printer_model = Bambu Lab X1 Carbon" or "; printer_model = X1C"
+            if "sliced_for_model" not in self.metadata:
+                match = re.search(r";\s*printer_model\s*=\s*(.+)", header, re.IGNORECASE)
+                if match:
+                    from backend.app.utils.printer_models import normalize_printer_model
+
+                    raw_model = match.group(1).strip()
+                    self.metadata["sliced_for_model"] = normalize_printer_model(raw_model)
         except Exception:
         except Exception:
             pass
             pass
 
 
@@ -256,6 +279,12 @@ class ThreeMFParser:
                     elif isinstance(val, (int, float, str)):
                     elif isinstance(val, (int, float, str)):
                         self.metadata["nozzle_temperature"] = int(float(val))
                         self.metadata["nozzle_temperature"] = int(float(val))
                     break
                     break
+
+            # Printer model (extract and normalize)
+            if "printer_model" in data:
+                from backend.app.utils.printer_models import normalize_printer_model
+
+                self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"])
         except Exception:
         except Exception:
             pass
             pass
 
 
@@ -877,6 +906,7 @@ class ArchiveService:
             nozzle_diameter=metadata.get("nozzle_diameter"),
             nozzle_diameter=metadata.get("nozzle_diameter"),
             bed_temperature=metadata.get("bed_temperature"),
             bed_temperature=metadata.get("bed_temperature"),
             nozzle_temperature=metadata.get("nozzle_temperature"),
             nozzle_temperature=metadata.get("nozzle_temperature"),
+            sliced_for_model=metadata.get("sliced_for_model"),
             makerworld_url=metadata.get("makerworld_url"),
             makerworld_url=metadata.get("makerworld_url"),
             designer=metadata.get("designer"),
             designer=metadata.get("designer"),
             status=status,
             status=status,

+ 1 - 1
backend/app/services/homeassistant.py

@@ -228,7 +228,7 @@ class HomeAssistantService:
             - domain: str
             - domain: str
         """
         """
         # Default domains for smart plug control
         # Default domains for smart plug control
-        default_domains = {"switch", "light", "input_boolean"}
+        default_domains = {"switch", "light", "input_boolean", "script"}
 
 
         try:
         try:
             async with httpx.AsyncClient(timeout=self.timeout) as client:
             async with httpx.AsyncClient(timeout=self.timeout) as client:

+ 150 - 0
backend/app/services/notification_service.py

@@ -945,6 +945,156 @@ class NotificationService:
         """Clear the template cache. Call this when templates are updated."""
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()
         self._template_cache.clear()
 
 
+    # ==================== Queue Notifications ====================
+
+    async def on_queue_job_added(
+        self,
+        job_name: str,
+        target: str,
+        db: AsyncSession,
+        printer_id: int | None = None,
+        printer_name: str | None = None,
+    ):
+        """Handle queue job added event."""
+        providers = await self._get_providers_for_event(db, "on_queue_job_added", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "job_name": job_name,
+            "target": target,  # e.g., "Printer1" or "Any X1C"
+            "printer": printer_name or target,
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_job_added", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_job_added", printer_id, printer_name)
+
+    async def on_queue_job_assigned(
+        self,
+        job_name: str,
+        printer_id: int,
+        printer_name: str,
+        target_model: str,
+        db: AsyncSession,
+    ):
+        """Handle model-based job assigned to printer event."""
+        providers = await self._get_providers_for_event(db, "on_queue_job_assigned", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "job_name": job_name,
+            "printer": printer_name,
+            "target_model": target_model,
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_job_assigned", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_job_assigned", printer_id, printer_name)
+
+    async def on_queue_job_started(
+        self,
+        job_name: str,
+        printer_id: int,
+        printer_name: str,
+        db: AsyncSession,
+        estimated_time: int | None = None,
+    ):
+        """Handle queue job started printing event."""
+        providers = await self._get_providers_for_event(db, "on_queue_job_started", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "job_name": job_name,
+            "printer": printer_name,
+            "estimated_time": self._format_duration(estimated_time),
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_job_started", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_job_started", printer_id, printer_name)
+
+    async def on_queue_job_waiting(
+        self,
+        job_name: str,
+        target_model: str,
+        waiting_reason: str,
+        db: AsyncSession,
+    ):
+        """Handle job waiting for filament event."""
+        providers = await self._get_providers_for_event(db, "on_queue_job_waiting", None)
+        if not providers:
+            return
+
+        variables = {
+            "job_name": job_name,
+            "target_model": target_model,
+            "waiting_reason": waiting_reason,
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_job_waiting", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_job_waiting")
+
+    async def on_queue_job_skipped(
+        self,
+        job_name: str,
+        printer_id: int,
+        printer_name: str,
+        reason: str,
+        db: AsyncSession,
+    ):
+        """Handle job skipped event (e.g., previous print failed)."""
+        providers = await self._get_providers_for_event(db, "on_queue_job_skipped", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "job_name": job_name,
+            "printer": printer_name,
+            "reason": reason,
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_job_skipped", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_job_skipped", printer_id, printer_name)
+
+    async def on_queue_job_failed(
+        self,
+        job_name: str,
+        printer_id: int | None,
+        printer_name: str | None,
+        reason: str,
+        db: AsyncSession,
+    ):
+        """Handle job failed to start event (upload error, etc.)."""
+        providers = await self._get_providers_for_event(db, "on_queue_job_failed", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "job_name": job_name,
+            "printer": printer_name or "Unknown",
+            "reason": reason,
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_job_failed", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_job_failed", printer_id, printer_name)
+
+    async def on_queue_completed(
+        self,
+        completed_count: int,
+        db: AsyncSession,
+    ):
+        """Handle all queue jobs completed event."""
+        providers = await self._get_providers_for_event(db, "on_queue_completed", None)
+        if not providers:
+            return
+
+        variables = {
+            "completed_count": str(completed_count),
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_completed", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_completed")
+
     async def _queue_for_digest(
     async def _queue_for_digest(
         self,
         self,
         provider: NotificationProvider,
         provider: NotificationProvider,

+ 2 - 0
backend/app/services/plate_detection.py

@@ -5,6 +5,8 @@ Uses calibration-based difference detection - compares current frame to
 a reference image of the empty plate.
 a reference image of the empty plate.
 """
 """
 
 
+from __future__ import annotations
+
 import logging
 import logging
 from pathlib import Path
 from pathlib import Path
 
 

+ 309 - 43
backend/app/services/print_scheduler.py

@@ -4,7 +4,7 @@ import asyncio
 import logging
 import logging
 from datetime import datetime
 from datetime import datetime
 
 
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.config import settings
 from backend.app.core.config import settings
@@ -15,8 +15,10 @@ from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
 from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
+from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
-from backend.app.services.tasmota import tasmota_service
+from backend.app.services.smart_plug_manager import smart_plug_manager
+from backend.app.utils.printer_models import normalize_printer_model
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -62,13 +64,10 @@ class PrintScheduler:
             if not items:
             if not items:
                 return
                 return
 
 
-            # Group by printer - only process first item per printer
-            processed_printers = set()
+            # Track busy printers to avoid assigning multiple items to same printer
+            busy_printers: set[int] = set()
 
 
             for item in items:
             for item in items:
-                if item.printer_id in processed_printers:
-                    continue
-
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                     continue
                     continue
@@ -77,46 +76,254 @@ class PrintScheduler:
                 if item.manual_start:
                 if item.manual_start:
                     continue
                     continue
 
 
-                # Check if printer is idle
-                printer_idle = self._is_printer_idle(item.printer_id)
-                printer_connected = printer_manager.is_connected(item.printer_id)
-
-                # If printer not connected, try to power on via smart plug
-                if not printer_connected:
-                    plug = await self._get_smart_plug(db, item.printer_id)
-                    if plug and plug.auto_on and plug.enabled:
-                        logger.info(f"Printer {item.printer_id} offline, attempting to power on via smart plug")
-                        powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
-                        if powered_on:
-                            printer_connected = True
-                            printer_idle = self._is_printer_idle(item.printer_id)
+                if item.printer_id:
+                    # Specific printer assignment (existing behavior)
+                    if item.printer_id in busy_printers:
+                        continue
+
+                    # Check if printer is idle
+                    printer_idle = self._is_printer_idle(item.printer_id)
+                    printer_connected = printer_manager.is_connected(item.printer_id)
+
+                    # If printer not connected, try to power on via smart plug
+                    if not printer_connected:
+                        plug = await self._get_smart_plug(db, item.printer_id)
+                        if plug and plug.auto_on and plug.enabled:
+                            logger.info(f"Printer {item.printer_id} offline, attempting to power on via smart plug")
+                            powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
+                            if powered_on:
+                                printer_connected = True
+                                printer_idle = self._is_printer_idle(item.printer_id)
+                            else:
+                                logger.warning(f"Could not power on printer {item.printer_id} via smart plug")
+                                busy_printers.add(item.printer_id)
+                                continue
                         else:
                         else:
-                            logger.warning(f"Could not power on printer {item.printer_id} via smart plug")
-                            processed_printers.add(item.printer_id)
+                            # No plug or auto_on disabled
+                            busy_printers.add(item.printer_id)
                             continue
                             continue
-                    else:
-                        # No plug or auto_on disabled
-                        processed_printers.add(item.printer_id)
+
+                    # Check if printer is idle (busy with another print)
+                    if not printer_idle:
+                        busy_printers.add(item.printer_id)
                         continue
                         continue
 
 
-                # Check if printer is idle (busy with another print)
-                if not printer_idle:
-                    processed_printers.add(item.printer_id)
-                    continue
+                    # Check condition (previous print success)
+                    if item.require_previous_success:
+                        if not await self._check_previous_success(db, item):
+                            item.status = "skipped"
+                            item.error_message = "Previous print failed or was aborted"
+                            item.completed_at = datetime.now()
+                            await db.commit()
+                            logger.info(f"Skipped queue item {item.id} - previous print failed")
+
+                            # Send notification
+                            job_name = await self._get_job_name(db, item)
+                            printer = await self._get_printer(db, item.printer_id)
+                            await notification_service.on_queue_job_skipped(
+                                job_name=job_name,
+                                printer_id=item.printer_id,
+                                printer_name=printer.name if printer else "Unknown",
+                                reason="Previous print failed or was aborted",
+                                db=db,
+                            )
+                            continue
 
 
-                # Check condition (previous print success)
-                if item.require_previous_success:
-                    if not await self._check_previous_success(db, item):
-                        item.status = "skipped"
-                        item.error_message = "Previous print failed or was aborted"
-                        item.completed_at = datetime.now()
+                    # Start the print
+                    await self._start_print(db, item)
+                    busy_printers.add(item.printer_id)
+
+                elif item.target_model:
+                    # Model-based assignment - find any idle printer of matching model
+                    # Parse required filament types if present
+                    required_types = None
+                    if item.required_filament_types:
+                        try:
+                            import json
+
+                            required_types = json.loads(item.required_filament_types)
+                        except json.JSONDecodeError:
+                            pass
+
+                    printer_id, waiting_reason = await self._find_idle_printer_for_model(
+                        db, item.target_model, busy_printers, required_types
+                    )
+
+                    # Update waiting_reason if changed and send notification when first waiting
+                    if item.waiting_reason != waiting_reason:
+                        was_waiting = item.waiting_reason is not None
+                        item.waiting_reason = waiting_reason
                         await db.commit()
                         await db.commit()
-                        logger.info(f"Skipped queue item {item.id} - previous print failed")
-                        continue
 
 
-                # Start the print
-                await self._start_print(db, item)
-                processed_printers.add(item.printer_id)
+                        # Send waiting notification only when transitioning to waiting state
+                        if waiting_reason and not was_waiting:
+                            job_name = await self._get_job_name(db, item)
+                            await notification_service.on_queue_job_waiting(
+                                job_name=job_name,
+                                target_model=item.target_model,
+                                waiting_reason=waiting_reason,
+                                db=db,
+                            )
+
+                    if printer_id:
+                        # Check condition (previous print success) before assigning
+                        if item.require_previous_success:
+                            if not await self._check_previous_success(db, item):
+                                item.status = "skipped"
+                                item.error_message = "Previous print failed or was aborted"
+                                item.completed_at = datetime.now()
+                                await db.commit()
+                                logger.info(f"Skipped queue item {item.id} - previous print failed")
+
+                                # Send notification
+                                job_name = await self._get_job_name(db, item)
+                                printer = await self._get_printer(db, printer_id)
+                                await notification_service.on_queue_job_skipped(
+                                    job_name=job_name,
+                                    printer_id=printer_id,
+                                    printer_name=printer.name if printer else "Unknown",
+                                    reason="Previous print failed or was aborted",
+                                    db=db,
+                                )
+                                continue
+
+                        # Assign printer and start - clear waiting reason
+                        item.printer_id = printer_id
+                        item.waiting_reason = None
+                        logger.info(f"Model-based assignment: queue item {item.id} assigned to printer {printer_id}")
+
+                        # Send assignment notification
+                        job_name = await self._get_job_name(db, item)
+                        printer = await self._get_printer(db, printer_id)
+                        await notification_service.on_queue_job_assigned(
+                            job_name=job_name,
+                            printer_id=printer_id,
+                            printer_name=printer.name if printer else "Unknown",
+                            target_model=item.target_model,
+                            db=db,
+                        )
+
+                        await self._start_print(db, item)
+                        busy_printers.add(printer_id)
+
+    async def _find_idle_printer_for_model(
+        self,
+        db: AsyncSession,
+        model: str,
+        exclude_ids: set[int],
+        required_filament_types: list[str] | None = None,
+    ) -> tuple[int | None, str | None]:
+        """Find an idle, connected printer matching the model with compatible filaments.
+
+        Args:
+            db: Database session
+            model: Printer model to match (e.g., "X1C", "P1S")
+            exclude_ids: Printer IDs to exclude (already busy)
+            required_filament_types: Optional list of filament types needed (e.g., ["PLA", "PETG"])
+                                     If provided, only printers with all required types loaded will match.
+
+        Returns:
+            Tuple of (printer_id, waiting_reason):
+            - (printer_id, None) if a matching printer was found
+            - (None, reason) if no printer is available, with explanation
+        """
+        # Normalize model name and use case-insensitive matching
+        normalized_model = normalize_printer_model(model) or model
+        result = await db.execute(
+            select(Printer)
+            .where(func.lower(Printer.model) == normalized_model.lower())
+            .where(Printer.is_active == True)  # noqa: E712
+        )
+        printers = list(result.scalars().all())
+
+        if not printers:
+            return None, f"No active {normalized_model} printers configured"
+
+        # Track reasons for skipping printers
+        printers_busy = []
+        printers_offline = []
+        printers_missing_filament = []
+
+        for printer in printers:
+            if printer.id in exclude_ids:
+                printers_busy.append(printer.name)
+                continue
+
+            is_connected = printer_manager.is_connected(printer.id)
+            is_idle = self._is_printer_idle(printer.id) if is_connected else False
+
+            if not is_connected:
+                printers_offline.append(printer.name)
+                continue
+
+            if not is_idle:
+                printers_busy.append(printer.name)
+                continue
+
+            # Validate filament compatibility if required types are specified
+            if required_filament_types:
+                missing = self._get_missing_filament_types(printer.id, required_filament_types)
+                if missing:
+                    printers_missing_filament.append((printer.name, missing))
+                    logger.debug(f"Skipping printer {printer.id} ({printer.name}) - missing filaments: {missing}")
+                    continue
+
+            # Found a matching printer - clear waiting reason
+            return printer.id, None
+
+        # Build waiting reason from what we found
+        reasons = []
+        if printers_missing_filament:
+            # Filament mismatch is most actionable - show first
+            names_and_missing = [f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament]
+            reasons.append(f"Waiting for filament: {'; '.join(names_and_missing)}")
+        if printers_busy:
+            reasons.append(f"Busy: {', '.join(printers_busy)}")
+        if printers_offline:
+            reasons.append(f"Offline: {', '.join(printers_offline)}")
+
+        return None, " | ".join(reasons) if reasons else f"No available {model} printers"
+
+    def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]:
+        """Get the list of required filament types that are not loaded on the printer.
+
+        Args:
+            printer_id: The printer ID
+            required_types: List of filament types needed (e.g., ["PLA", "PETG"])
+
+        Returns:
+            List of missing filament types (empty if all are loaded)
+        """
+        status = printer_manager.get_status(printer_id)
+        if not status:
+            return required_types  # Can't determine, assume all missing
+
+        # Collect all filament types loaded on this printer (AMS units + external spool)
+        loaded_types: set[str] = set()
+
+        # Check AMS units (stored in raw_data["ams"])
+        ams_data = status.raw_data.get("ams", [])
+        if ams_data:
+            for ams_unit in ams_data:
+                for tray in ams_unit.get("tray", []):
+                    tray_type = tray.get("tray_type")
+                    if tray_type:
+                        loaded_types.add(tray_type.upper())
+
+        # Check external spool (virtual tray, stored in raw_data["vt_tray"])
+        vt_tray = status.raw_data.get("vt_tray")
+        if vt_tray:
+            vt_type = vt_tray.get("tray_type")
+            if vt_type:
+                loaded_types.add(vt_type.upper())
+
+        # Find which required types are missing (case-insensitive comparison)
+        missing = []
+        for req_type in required_types:
+            if req_type.upper() not in loaded_types:
+                missing.append(req_type)
+
+        return missing
 
 
     def _is_printer_idle(self, printer_id: int) -> bool:
     def _is_printer_idle(self, printer_id: int) -> bool:
         """Check if a printer is connected and idle."""
         """Check if a printer is connected and idle."""
@@ -141,15 +348,18 @@ class PrintScheduler:
 
 
         Returns True if printer connected successfully within timeout.
         Returns True if printer connected successfully within timeout.
         """
         """
+        # Get the appropriate service for the plug type (Tasmota or Home Assistant)
+        service = await smart_plug_manager.get_service_for_plug(plug, db)
+
         # Check current plug state
         # Check current plug state
-        status = await tasmota_service.get_status(plug)
+        status = await service.get_status(plug)
         if not status.get("reachable"):
         if not status.get("reachable"):
             logger.warning(f"Smart plug '{plug.name}' is not reachable")
             logger.warning(f"Smart plug '{plug.name}' is not reachable")
             return False
             return False
 
 
         # Turn on if not already on
         # Turn on if not already on
         if status.get("state") != "ON":
         if status.get("state") != "ON":
-            success = await tasmota_service.turn_on(plug)
+            success = await service.turn_on(plug)
             if not success:
             if not success:
                 logger.warning(f"Failed to turn on smart plug '{plug.name}'")
                 logger.warning(f"Failed to turn on smart plug '{plug.name}'")
                 return False
                 return False
@@ -218,7 +428,27 @@ class PrintScheduler:
             # Wait for cooldown (up to 10 minutes)
             # Wait for cooldown (up to 10 minutes)
             await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
             await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
             logger.info(f"Auto-off: Powering off printer {item.printer_id}")
             logger.info(f"Auto-off: Powering off printer {item.printer_id}")
-            await tasmota_service.turn_off(plug)
+            service = await smart_plug_manager.get_service_for_plug(plug, db)
+            await service.turn_off(plug)
+
+    async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:
+        """Get a human-readable name for a queue item."""
+        if item.archive_id:
+            result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
+            archive = result.scalar_one_or_none()
+            if archive:
+                return archive.filename.replace(".gcode.3mf", "").replace(".3mf", "")
+        if item.library_file_id:
+            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
+            library_file = result.scalar_one_or_none()
+            if library_file:
+                return library_file.filename.replace(".gcode.3mf", "").replace(".3mf", "")
+        return f"Job #{item.id}"
+
+    async def _get_printer(self, db: AsyncSession, printer_id: int) -> Printer | None:
+        """Get printer by ID."""
+        result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        return result.scalar_one_or_none()
 
 
     async def _start_print(self, db: AsyncSession, item: PrintQueueItem):
     async def _start_print(self, db: AsyncSession, item: PrintQueueItem):
         """Upload file and start print for a queue item.
         """Upload file and start print for a queue item.
@@ -371,6 +601,16 @@ class PrintScheduler:
             item.completed_at = datetime.utcnow()
             item.completed_at = datetime.utcnow()
             await db.commit()
             await db.commit()
             logger.error(f"Queue item {item.id}: FTP upload failed")
             logger.error(f"Queue item {item.id}: FTP upload failed")
+
+            # Send failure notification
+            await notification_service.on_queue_job_failed(
+                job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
+                printer_id=printer.id,
+                printer_name=printer.name,
+                reason="Failed to upload file to printer",
+                db=db,
+            )
+
             await self._power_off_if_needed(db, item)
             await self._power_off_if_needed(db, item)
             return
             return
 
 
@@ -411,6 +651,22 @@ class PrintScheduler:
             await db.commit()
             await db.commit()
             logger.info(f"Queue item {item.id}: Print started - {filename}")
             logger.info(f"Queue item {item.id}: Print started - {filename}")
 
 
+            # Get estimated time for notification
+            estimated_time = None
+            if archive and archive.print_time_seconds:
+                estimated_time = archive.print_time_seconds
+            elif library_file and library_file.print_time_seconds:
+                estimated_time = library_file.print_time_seconds
+
+            # Send job started notification
+            await notification_service.on_queue_job_started(
+                job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
+                printer_id=printer.id,
+                printer_name=printer.name,
+                db=db,
+                estimated_time=estimated_time,
+            )
+
             # MQTT relay - publish queue job started
             # MQTT relay - publish queue job started
             try:
             try:
                 from backend.app.services.mqtt_relay import mqtt_relay
                 from backend.app.services.mqtt_relay import mqtt_relay
@@ -430,6 +686,16 @@ class PrintScheduler:
             item.completed_at = datetime.utcnow()
             item.completed_at = datetime.utcnow()
             await db.commit()
             await db.commit()
             logger.error(f"Queue item {item.id}: Failed to start print")
             logger.error(f"Queue item {item.id}: Failed to start print")
+
+            # Send failure notification
+            await notification_service.on_queue_job_failed(
+                job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
+                printer_id=printer.id,
+                printer_name=printer.name,
+                reason="Failed to send print command",
+                db=db,
+            )
+
             await self._power_off_if_needed(db, item)
             await self._power_off_if_needed(db, item)
 
 
 
 

+ 11 - 6
backend/app/services/smart_plug_manager.py

@@ -27,7 +27,7 @@ class SmartPlugManager:
         self._scheduler_task: asyncio.Task | None = None
         self._scheduler_task: asyncio.Task | None = None
         self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
         self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
 
 
-    async def _get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
+    async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
         """Get the appropriate service for the plug type.
         """Get the appropriate service for the plug type.
 
 
         For HA plugs, configures the service with current settings from DB.
         For HA plugs, configures the service with current settings from DB.
@@ -110,7 +110,7 @@ class SmartPlugManager:
             plugs = result.scalars().all()
             plugs = result.scalars().all()
 
 
             for plug in plugs:
             for plug in plugs:
-                service = await self._get_service_for_plug(plug, db)
+                service = await self.get_service_for_plug(plug, db)
 
 
                 # Check if we should turn on
                 # Check if we should turn on
                 if plug.schedule_on_time == current_time:
                 if plug.schedule_on_time == current_time:
@@ -166,7 +166,7 @@ class SmartPlugManager:
 
 
         # Turn on the plug
         # Turn on the plug
         logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
         logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
-        service = await self._get_service_for_plug(plug, db)
+        service = await self.get_service_for_plug(plug, db)
         success = await service.turn_on(plug)
         success = await service.turn_on(plug)
 
 
         if success:
         if success:
@@ -195,6 +195,11 @@ class SmartPlugManager:
             logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
             logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
             return
             return
 
 
+        # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
+        if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
+            logger.debug(f"Smart plug '{plug.name}' is a HA script entity, skipping auto-off")
+            return
+
         # Only auto-off on successful completion, not on failures
         # Only auto-off on successful completion, not on failures
         # This allows the user to investigate errors before power-off
         # This allows the user to investigate errors before power-off
         if status != "completed":
         if status != "completed":
@@ -261,7 +266,7 @@ class SmartPlugManager:
                     self.name = f"plug_{plug_id}"
                     self.name = f"plug_{plug_id}"
 
 
             plug_info = PlugInfo()
             plug_info = PlugInfo()
-            service = await self._get_service_for_plug(plug_info)
+            service = await self.get_service_for_plug(plug_info)
             success = await service.turn_off(plug_info)
             success = await service.turn_off(plug_info)
             logger.info(f"Turned off plug {plug_id} after time delay")
             logger.info(f"Turned off plug {plug_id} after time delay")
 
 
@@ -353,7 +358,7 @@ class SmartPlugManager:
                                 self.name = f"plug_{plug_id}"
                                 self.name = f"plug_{plug_id}"
 
 
                         plug_info = PlugInfo()
                         plug_info = PlugInfo()
-                        service = await self._get_service_for_plug(plug_info)
+                        service = await self.get_service_for_plug(plug_info)
                         success = await service.turn_off(plug_info)
                         success = await service.turn_off(plug_info)
                         logger.info(
                         logger.info(
                             f"Turned off plug {plug_id} after nozzle temp dropped to "
                             f"Turned off plug {plug_id} after nozzle temp dropped to "
@@ -474,7 +479,7 @@ class SmartPlugManager:
                         # For time mode, just turn off immediately since delay already passed
                         # For time mode, just turn off immediately since delay already passed
                         logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
                         logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
 
 
-                        service = await self._get_service_for_plug(plug, db)
+                        service = await self.get_service_for_plug(plug, db)
                         success = await service.turn_off(plug)
                         success = await service.turn_off(plug)
                         if success:
                         if success:
                             await self._mark_auto_off_executed(plug.id)
                             await self._mark_auto_off_executed(plug.id)

+ 7 - 2
backend/app/services/spoolman.py

@@ -541,10 +541,15 @@ class SpoolmanClient:
 
 
         # Need valid color to create filament
         # Need valid color to create filament
         tray_color = tray_data.get("tray_color", "")
         tray_color = tray_data.get("tray_color", "")
-        if not tray_color or tray_color in ("", "00000000"):
-            logger.debug(f"Skipping tray with invalid color: {tray_color}")
+        if not tray_color or tray_color.strip() == "":
+            logger.debug("Skipping tray with empty color")
             return None
             return None
 
 
+        # Handle transparent/natural filament (RRGGBBAA with alpha=00)
+        # Replace with cream color that represents how natural PLA actually looks
+        if tray_color == "00000000":
+            tray_color = "F5E6D3FF"  # Light cream/natural color
+
         # Get sub_brands, falling back to tray_type
         # Get sub_brands, falling back to tray_type
         tray_sub_brands = tray_data.get("tray_sub_brands", "")
         tray_sub_brands = tray_data.get("tray_sub_brands", "")
         if not tray_sub_brands or tray_sub_brands.strip() == "":
         if not tray_sub_brands or tray_sub_brands.strip() == "":

+ 140 - 0
backend/app/services/stl_thumbnail.py

@@ -0,0 +1,140 @@
+"""STL Thumbnail Generation Service.
+
+Generates thumbnail images from STL files using trimesh and matplotlib.
+"""
+
+import logging
+import uuid
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Bambu green color for rendering
+BAMBU_GREEN = "#00AE42"
+BACKGROUND_COLOR = "#1a1a1a"
+
+# Maximum vertices before simplification
+MAX_VERTICES = 100000
+
+
+def generate_stl_thumbnail(
+    stl_path: Path,
+    thumbnails_dir: Path,
+    size: int = 256,
+) -> str | None:
+    """Generate a thumbnail image from an STL file.
+
+    Args:
+        stl_path: Path to the STL file
+        thumbnails_dir: Directory to save the thumbnail
+        size: Thumbnail size in pixels (default 256x256)
+
+    Returns:
+        Path to the generated thumbnail, or None on failure
+    """
+    try:
+        import matplotlib
+        import trimesh
+
+        # Use Agg backend for headless rendering
+        matplotlib.use("Agg")
+        import matplotlib.pyplot as plt
+        from mpl_toolkits.mplot3d import Axes3D  # noqa: F401
+        from mpl_toolkits.mplot3d.art3d import Poly3DCollection
+
+        # Load the STL file
+        mesh = trimesh.load(str(stl_path), force="mesh")
+
+        if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
+            logger.warning(f"Failed to load STL or empty mesh: {stl_path}")
+            return None
+
+        # Simplify large meshes for performance
+        if len(mesh.vertices) > MAX_VERTICES:
+            logger.info(f"Simplifying mesh from {len(mesh.vertices)} vertices")
+            try:
+                # Calculate reduction ratio (0-1 range)
+                # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%
+                keep_ratio = MAX_VERTICES / len(mesh.vertices)
+                target_reduction = 1.0 - keep_ratio
+                # Clamp to valid range (0.01 to 0.99)
+                target_reduction = max(0.01, min(0.99, target_reduction))
+                mesh = mesh.simplify_quadric_decimation(target_reduction)
+                logger.info(f"Simplified mesh to {len(mesh.vertices)} vertices")
+            except Exception as e:
+                logger.warning(f"Mesh simplification failed, using original: {e}")
+
+        # Get mesh bounds and center it
+        vertices = mesh.vertices
+        bounds_min = vertices.min(axis=0)
+        bounds_max = vertices.max(axis=0)
+        center = (bounds_min + bounds_max) / 2
+        vertices_centered = vertices - center
+
+        # Scale to fit in view
+        max_extent = (bounds_max - bounds_min).max()
+        if max_extent > 0:
+            scale = 1.0 / max_extent
+            vertices_scaled = vertices_centered * scale
+        else:
+            vertices_scaled = vertices_centered
+
+        # Create figure with dark background
+        fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
+        fig.patch.set_facecolor(BACKGROUND_COLOR)
+
+        ax = fig.add_subplot(111, projection="3d")
+        ax.set_facecolor(BACKGROUND_COLOR)
+
+        # Create polygon collection from mesh faces
+        faces = mesh.faces
+        poly3d = [[vertices_scaled[vertex] for vertex in face] for face in faces]
+
+        collection = Poly3DCollection(
+            poly3d,
+            facecolors=BAMBU_GREEN,
+            edgecolors=BAMBU_GREEN,
+            linewidths=0.1,
+            alpha=0.9,
+        )
+        ax.add_collection3d(collection)
+
+        # Set axis limits
+        ax.set_xlim(-0.6, 0.6)
+        ax.set_ylim(-0.6, 0.6)
+        ax.set_zlim(-0.6, 0.6)
+
+        # Set view angle (isometric-ish)
+        ax.view_init(elev=25, azim=45)
+
+        # Remove axes and grid
+        ax.set_axis_off()
+        ax.grid(False)
+
+        # Remove margins
+        plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
+
+        # Save thumbnail
+        thumb_filename = f"{uuid.uuid4().hex}.png"
+        thumb_path = thumbnails_dir / thumb_filename
+
+        fig.savefig(
+            thumb_path,
+            format="png",
+            facecolor=BACKGROUND_COLOR,
+            edgecolor="none",
+            bbox_inches="tight",
+            pad_inches=0.05,
+            dpi=100,
+        )
+        plt.close(fig)
+
+        logger.info(f"Generated STL thumbnail: {thumb_path}")
+        return str(thumb_path)
+
+    except ImportError as e:
+        logger.warning(f"STL thumbnail generation unavailable (missing dependencies): {e}")
+        return None
+    except Exception as e:
+        logger.warning(f"Failed to generate STL thumbnail for {stl_path}: {e}")
+        return None

+ 86 - 0
backend/app/utils/printer_models.py

@@ -0,0 +1,86 @@
+"""Printer model normalization utilities.
+
+Converts 3MF printer model names (e.g., "Bambu Lab X1 Carbon") to
+normalized short names (e.g., "X1C") that match database storage.
+"""
+
+# Map from 3MF printer_model strings to normalized short names
+PRINTER_MODEL_MAP = {
+    "Bambu Lab X1 Carbon": "X1C",
+    "Bambu Lab X1": "X1",
+    "Bambu Lab X1E": "X1E",
+    "Bambu Lab P1S": "P1S",
+    "Bambu Lab P1P": "P1P",
+    "Bambu Lab P2S": "P2S",
+    "Bambu Lab A1": "A1",
+    "Bambu Lab A1 Mini": "A1 Mini",
+    "Bambu Lab A1 mini": "A1 Mini",
+    "Bambu Lab H2D": "H2D",
+    "Bambu Lab H2D Pro": "H2D Pro",
+}
+
+# Map from printer_model_id (internal codes in slice_info.config) to short names
+# These are the codes Bambu Studio uses internally
+PRINTER_MODEL_ID_MAP = {
+    # X1 series
+    "C11": "X1C",
+    "C12": "X1",
+    "C13": "X1E",
+    # P1 series
+    "P1P": "P1P",
+    "P1S": "P1S",
+    # P2 series
+    "P2S": "P2S",
+    # A1 series
+    "A11": "A1",
+    "A12": "A1 Mini",
+    "N1": "A1",
+    "N2S": "A1 Mini",
+    "A04": "A1 Mini",
+    # H2D series (Office/H series)
+    "O1D": "H2D",
+    "O2D": "H2D Pro",
+}
+
+
+def normalize_printer_model_id(model_id: str | None) -> str | None:
+    """Convert printer_model_id (internal code) to normalized short name.
+
+    Args:
+        model_id: The printer_model_id from slice_info.config (e.g., "C11", "O1D")
+
+    Returns:
+        Normalized short name (e.g., "X1C", "H2D") or the original ID if unknown.
+    """
+    if not model_id:
+        return None
+
+    # Check known mappings
+    if model_id in PRINTER_MODEL_ID_MAP:
+        return PRINTER_MODEL_ID_MAP[model_id]
+
+    # Return original if unknown (might already be a short name)
+    return model_id
+
+
+def normalize_printer_model(raw_model: str | None) -> str | None:
+    """Convert 3MF printer_model to normalized short name.
+
+    Args:
+        raw_model: The printer_model string from 3MF metadata
+            (e.g., "Bambu Lab X1 Carbon")
+
+    Returns:
+        Normalized short name (e.g., "X1C") or None if input is empty.
+        Unknown models have "Bambu Lab " prefix stripped.
+    """
+    if not raw_model:
+        return None
+
+    # Check known mappings first
+    if raw_model in PRINTER_MODEL_MAP:
+        return PRINTER_MODEL_MAP[raw_model]
+
+    # Strip "Bambu Lab " prefix for unknown models
+    stripped = raw_model.replace("Bambu Lab ", "").strip()
+    return stripped or None

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

@@ -434,3 +434,127 @@ class TestArchiveF3DEndpoints:
         """Verify filament-requirements with plate_id returns 404 for non-existent archive."""
         """Verify filament-requirements with plate_id returns 404 for non-existent archive."""
         response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
         response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
         assert response.status_code == 404
         assert response.status_code == 404
+
+    # ========================================================================
+    # Tag Management endpoints (Issue #183)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_tags_empty(self, async_client: AsyncClient):
+        """Verify empty list when no tags exist."""
+        response = await async_client.get("/api/v1/archives/tags")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert len(data) == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify tags are returned with counts."""
+        printer = await printer_factory()
+        await archive_factory(printer.id, print_name="Archive 1", tags="functional, test")
+        await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration")
+        await archive_factory(printer.id, print_name="Archive 3", tags="test")
+
+        response = await async_client.get("/api/v1/archives/tags")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+
+        # Convert to dict for easier lookup
+        tags_dict = {t["name"]: t["count"] for t in data}
+        assert tags_dict.get("functional") == 2
+        assert tags_dict.get("test") == 2
+        assert tags_dict.get("calibration") == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_tags_sorted_by_count(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify tags are sorted by count descending, then by name."""
+        printer = await printer_factory()
+        await archive_factory(printer.id, tags="alpha")
+        await archive_factory(printer.id, tags="beta, alpha")
+        await archive_factory(printer.id, tags="gamma, beta, alpha")
+
+        response = await async_client.get("/api/v1/archives/tags")
+        assert response.status_code == 200
+        data = response.json()
+
+        # alpha=3, beta=2, gamma=1
+        assert data[0]["name"] == "alpha"
+        assert data[0]["count"] == 3
+        assert data[1]["name"] == "beta"
+        assert data[1]["count"] == 2
+        assert data[2]["name"] == "gamma"
+        assert data[2]["count"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify renaming a tag updates all archives."""
+        printer = await printer_factory()
+        a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other")
+        a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag")
+        await archive_factory(printer.id, print_name="Archive 3", tags="different")
+
+        response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"})
+        assert response.status_code == 200
+        data = response.json()
+        assert data["affected"] == 2
+
+        # Verify the archives were updated
+        response = await async_client.get(f"/api/v1/archives/{a1.id}")
+        assert "new-tag" in response.json()["tags"]
+        assert "old-tag" not in response.json()["tags"]
+
+        response = await async_client.get(f"/api/v1/archives/{a2.id}")
+        assert response.json()["tags"] == "new-tag"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag_no_change(self, async_client: AsyncClient):
+        """Verify renaming to same name returns 0 affected."""
+        response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"})
+        assert response.status_code == 200
+        assert response.json()["affected"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):
+        """Verify renaming to empty name returns error."""
+        response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""})
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify deleting a tag removes it from all archives."""
+        printer = await printer_factory()
+        a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep")
+        a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me")
+        await archive_factory(printer.id, print_name="Archive 3", tags="different")
+
+        response = await async_client.delete("/api/v1/archives/tags/delete-me")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["affected"] == 2
+
+        # Verify the archives were updated
+        response = await async_client.get(f"/api/v1/archives/{a1.id}")
+        assert response.json()["tags"] == "keep"
+
+        response = await async_client.get(f"/api/v1/archives/{a2.id}")
+        # Should be None or empty when last tag is removed
+        assert response.json()["tags"] is None or response.json()["tags"] == ""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_tag_not_found(self, async_client: AsyncClient):
+        """Verify deleting non-existent tag returns 0 affected."""
+        response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
+        assert response.status_code == 200
+        assert response.json()["affected"] == 0

+ 27 - 5
backend/tests/integration/test_camera_api.py

@@ -285,6 +285,7 @@ class TestCameraAPI:
         mock_result.difference_percent = 0.5
         mock_result.difference_percent = 0.5
         mock_result.message = "Plate appears empty"
         mock_result.message = "Plate appears empty"
         mock_result.needs_calibration = False
         mock_result.needs_calibration = False
+        mock_result.debug_image = None
         mock_result.to_dict.return_value = {
         mock_result.to_dict.return_value = {
             "is_empty": True,
             "is_empty": True,
             "confidence": 0.95,
             "confidence": 0.95,
@@ -294,7 +295,16 @@ class TestCameraAPI:
             "needs_calibration": False,
             "needs_calibration": False,
         }
         }
 
 
-        with patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check:
+        # Mock PlateDetector for reference count
+        mock_detector = MagicMock()
+        mock_detector.get_calibration_count.return_value = 0
+        mock_detector.MAX_REFERENCES = 5
+
+        with (
+            patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check,
+            patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
+        ):
             mock_check.return_value = mock_result
             mock_check.return_value = mock_result
             response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
             response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
 
 
@@ -319,7 +329,10 @@ class TestCameraAPI:
         printer = await printer_factory()
         printer = await printer_factory()
 
 
         # Mock calibrate_plate at the source module to avoid camera timeout
         # Mock calibrate_plate at the source module to avoid camera timeout
-        with patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate:
+        with (
+            patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate,
+        ):
             mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
             mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
             response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
             response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
 
 
@@ -342,7 +355,8 @@ class TestCameraAPI:
         """Verify delete calibration returns proper structure."""
         """Verify delete calibration returns proper structure."""
         printer = await printer_factory()
         printer = await printer_factory()
 
 
-        response = await async_client.delete(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
+        with patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True):
+            response = await async_client.delete(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
@@ -374,8 +388,16 @@ class TestCameraAPI:
         """Verify get references returns proper structure."""
         """Verify get references returns proper structure."""
         printer = await printer_factory()
         printer = await printer_factory()
 
 
-        # OpenCV is available in test environment, just check the response structure
-        response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/references")
+        # Mock OpenCV availability and PlateDetector
+        mock_detector = MagicMock()
+        mock_detector.get_references.return_value = []
+        mock_detector.MAX_REFERENCES = 5
+
+        with (
+            patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/references")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()

+ 236 - 0
backend/tests/integration/test_library_api.py

@@ -1,5 +1,10 @@
 """Integration tests for Library API endpoints."""
 """Integration tests for Library API endpoints."""
 
 
+import io
+import tempfile
+import zipfile
+from pathlib import Path
+
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 
 
@@ -479,3 +484,234 @@ class TestLibraryZipExtractAPI:
         assert folder_response.status_code == 200
         assert folder_response.status_code == 200
         folder = folder_response.json()
         folder = folder_response.json()
         assert folder["name"] == "MyProject"
         assert folder["name"] == "MyProject"
+
+
+class TestLibraryStlThumbnailAPI:
+    """Integration tests for STL thumbnail generation endpoints."""
+
+    @pytest.fixture
+    async def file_factory(self, db_session):
+        """Factory to create test files."""
+        _counter = [0]
+
+        async def _create_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"test_model_{counter}.stl",
+                "file_path": f"/test/path/test_model_{counter}.stl",
+                "file_size": 1024,
+                "file_type": "stl",
+            }
+            defaults.update(kwargs)
+
+            lib_file = LibraryFile(**defaults)
+            db_session.add(lib_file)
+            await db_session.commit()
+            await db_session.refresh(lib_file)
+            return lib_file
+
+        return _create_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_empty(self, async_client: AsyncClient, db_session):
+        """Verify batch thumbnail generation with no files."""
+        data = {"all_missing": True}
+        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["processed"] == 0
+        assert result["succeeded"] == 0
+        assert result["failed"] == 0
+        assert result["results"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_no_criteria(self, async_client: AsyncClient, db_session):
+        """Verify batch thumbnail generation with no criteria returns empty."""
+        data = {}
+        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["processed"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_file_not_on_disk(
+        self, async_client: AsyncClient, file_factory, db_session
+    ):
+        """Verify batch thumbnail generation handles missing files gracefully."""
+        # Create a file in DB but not on disk
+        stl_file = await file_factory(
+            filename="missing.stl",
+            file_path="/nonexistent/path/missing.stl",
+            thumbnail_path=None,
+        )
+
+        data = {"file_ids": [stl_file.id]}
+        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["processed"] == 1
+        assert result["succeeded"] == 0
+        assert result["failed"] == 1
+        assert result["results"][0]["success"] is False
+        assert "not found" in result["results"][0]["error"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_with_real_stl(self, async_client: AsyncClient, db_session):
+        """Verify batch thumbnail generation with a real STL file."""
+        from backend.app.models.library import LibraryFile
+
+        # Create a simple ASCII STL cube
+        stl_content = """solid cube
+facet normal 0 0 -1
+  outer loop
+    vertex 0 0 0
+    vertex 1 0 0
+    vertex 1 1 0
+  endloop
+endfacet
+facet normal 0 0 1
+  outer loop
+    vertex 0 0 1
+    vertex 1 1 1
+    vertex 1 0 1
+  endloop
+endfacet
+endsolid cube"""
+
+        with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as f:
+            f.write(stl_content)
+            stl_path = f.name
+
+        try:
+            # Create file in DB pointing to real STL
+            lib_file = LibraryFile(
+                filename="test_cube.stl",
+                file_path=stl_path,
+                file_size=len(stl_content),
+                file_type="stl",
+                thumbnail_path=None,
+            )
+            db_session.add(lib_file)
+            await db_session.commit()
+            await db_session.refresh(lib_file)
+
+            data = {"file_ids": [lib_file.id]}
+            response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+            assert response.status_code == 200
+            result = response.json()
+            assert result["processed"] == 1
+            # Result depends on whether trimesh/matplotlib are installed
+            # Either succeeds or fails gracefully
+            assert result["succeeded"] + result["failed"] == 1
+        finally:
+            import os
+
+            if os.path.exists(stl_path):
+                os.unlink(stl_path)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_file_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
+        """Verify file upload accepts generate_stl_thumbnails parameter."""
+        # Create a simple STL file
+        stl_content = b"solid test\nendsolid test"
+
+        files = {"file": ("test.stl", stl_content, "application/octet-stream")}
+        params = {"generate_stl_thumbnails": "false"}
+        response = await async_client.post("/api/v1/library/files", files=files, params=params)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["filename"] == "test.stl"
+        assert result["file_type"] == "stl"
+        # No thumbnail should be generated when disabled
+        assert result["thumbnail_path"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
+        """Verify ZIP extraction accepts generate_stl_thumbnails parameter."""
+        # Create a ZIP file containing an STL
+        stl_content = b"solid test\nendsolid test"
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("model.stl", stl_content)
+        zip_buffer.seek(0)
+
+        files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
+        params = {"generate_stl_thumbnails": "false"}
+        response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["extracted"] == 1
+        assert result["files"][0]["filename"] == "model.stl"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_by_folder(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify batch thumbnail generation can filter by folder."""
+        from backend.app.models.library import LibraryFolder
+
+        # Create a folder
+        folder = LibraryFolder(name="STL Folder")
+        db_session.add(folder)
+        await db_session.commit()
+        await db_session.refresh(folder)
+
+        # Create STL file in folder (no thumbnail)
+        stl_in_folder = await file_factory(
+            filename="in_folder.stl",
+            folder_id=folder.id,
+            thumbnail_path=None,
+        )
+
+        # Create STL file at root (no thumbnail)
+        _stl_at_root = await file_factory(
+            filename="at_root.stl",
+            folder_id=None,
+            thumbnail_path=None,
+        )
+
+        # Request thumbnails only for files in folder
+        data = {"folder_id": folder.id, "all_missing": True}
+        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        # Should only process the file in the folder
+        assert result["processed"] == 1
+        assert result["results"][0]["file_id"] == stl_in_folder.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_all_missing(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify batch thumbnail generation finds all STL files missing thumbnails."""
+        # Create files with and without thumbnails
+        _stl_with_thumb = await file_factory(
+            filename="with_thumb.stl",
+            thumbnail_path="/some/path/thumb.png",
+        )
+        stl_without_thumb1 = await file_factory(
+            filename="without_thumb1.stl",
+            thumbnail_path=None,
+        )
+        stl_without_thumb2 = await file_factory(
+            filename="without_thumb2.stl",
+            thumbnail_path=None,
+        )
+
+        data = {"all_missing": True}
+        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        # Should only process files without thumbnails
+        assert result["processed"] == 2
+        file_ids = {r["file_id"] for r in result["results"]}
+        assert stl_without_thumb1.id in file_ids
+        assert stl_without_thumb2.id in file_ids

+ 204 - 0
backend/tests/unit/services/test_stl_thumbnail.py

@@ -0,0 +1,204 @@
+"""Unit tests for the STL thumbnail service."""
+
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+def _check_trimesh_available():
+    """Check if trimesh is available for import."""
+    try:
+        import trimesh
+
+        return True
+    except ImportError:
+        return False
+
+
+class TestStlThumbnailService:
+    """Tests for STL thumbnail generation service."""
+
+    def test_generate_stl_thumbnail_imports_available(self):
+        """Test that required imports are available."""
+        try:
+            import matplotlib
+            import trimesh
+
+            assert trimesh is not None
+            assert matplotlib is not None
+        except ImportError as e:
+            pytest.skip(f"Required dependencies not installed: {e}")
+
+    def test_generate_stl_thumbnail_returns_none_on_missing_deps(self):
+        """Test graceful degradation when dependencies are missing."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "test.stl"
+            thumbnails_dir = Path(tmpdir)
+
+            # Create a dummy STL file (will fail to parse)
+            stl_path.write_text("invalid stl content")
+
+            # Should return None on failure, not raise
+            result = generate_stl_thumbnail(stl_path, thumbnails_dir)
+            assert result is None
+
+    @pytest.mark.skipif(
+        not _check_trimesh_available(),
+        reason="trimesh not installed",
+    )
+    def test_generate_stl_thumbnail_with_simple_cube(self):
+        """Test thumbnail generation with a simple cube STL."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "cube.stl"
+            thumbnails_dir = Path(tmpdir)
+
+            # Create a simple ASCII STL cube
+            stl_content = """solid cube
+facet normal 0 0 -1
+  outer loop
+    vertex 0 0 0
+    vertex 1 0 0
+    vertex 1 1 0
+  endloop
+endfacet
+facet normal 0 0 -1
+  outer loop
+    vertex 0 0 0
+    vertex 1 1 0
+    vertex 0 1 0
+  endloop
+endfacet
+facet normal 0 0 1
+  outer loop
+    vertex 0 0 1
+    vertex 1 1 1
+    vertex 1 0 1
+  endloop
+endfacet
+facet normal 0 0 1
+  outer loop
+    vertex 0 0 1
+    vertex 0 1 1
+    vertex 1 1 1
+  endloop
+endfacet
+facet normal 0 -1 0
+  outer loop
+    vertex 0 0 0
+    vertex 1 0 1
+    vertex 1 0 0
+  endloop
+endfacet
+facet normal 0 -1 0
+  outer loop
+    vertex 0 0 0
+    vertex 0 0 1
+    vertex 1 0 1
+  endloop
+endfacet
+facet normal 1 0 0
+  outer loop
+    vertex 1 0 0
+    vertex 1 0 1
+    vertex 1 1 1
+  endloop
+endfacet
+facet normal 1 0 0
+  outer loop
+    vertex 1 0 0
+    vertex 1 1 1
+    vertex 1 1 0
+  endloop
+endfacet
+facet normal 0 1 0
+  outer loop
+    vertex 0 1 0
+    vertex 1 1 0
+    vertex 1 1 1
+  endloop
+endfacet
+facet normal 0 1 0
+  outer loop
+    vertex 0 1 0
+    vertex 1 1 1
+    vertex 0 1 1
+  endloop
+endfacet
+facet normal -1 0 0
+  outer loop
+    vertex 0 0 0
+    vertex 0 1 0
+    vertex 0 1 1
+  endloop
+endfacet
+facet normal -1 0 0
+  outer loop
+    vertex 0 0 0
+    vertex 0 1 1
+    vertex 0 0 1
+  endloop
+endfacet
+endsolid cube"""
+            stl_path.write_text(stl_content)
+
+            result = generate_stl_thumbnail(stl_path, thumbnails_dir)
+
+            # Should return a path to the generated thumbnail
+            if result:
+                assert Path(result).exists()
+                assert Path(result).suffix == ".png"
+            # If result is None, dependencies might not be fully functional
+            # which is acceptable
+
+    def test_generate_stl_thumbnail_nonexistent_file(self):
+        """Test thumbnail generation with nonexistent file."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "nonexistent.stl"
+            thumbnails_dir = Path(tmpdir)
+
+            result = generate_stl_thumbnail(stl_path, thumbnails_dir)
+            assert result is None
+
+    def test_generate_stl_thumbnail_empty_file(self):
+        """Test thumbnail generation with empty file."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "empty.stl"
+            thumbnails_dir = Path(tmpdir)
+
+            # Create empty file
+            stl_path.write_bytes(b"")
+
+            result = generate_stl_thumbnail(stl_path, thumbnails_dir)
+            assert result is None
+
+
+class TestStlThumbnailConstants:
+    """Tests for STL thumbnail service constants."""
+
+    def test_bambu_green_color(self):
+        """Test that Bambu green color is defined."""
+        from backend.app.services.stl_thumbnail import BAMBU_GREEN
+
+        assert BAMBU_GREEN == "#00AE42"
+
+    def test_background_color(self):
+        """Test that background color is defined."""
+        from backend.app.services.stl_thumbnail import BACKGROUND_COLOR
+
+        assert BACKGROUND_COLOR == "#1a1a1a"
+
+    def test_max_vertices_threshold(self):
+        """Test that max vertices threshold is defined."""
+        from backend.app.services.stl_thumbnail import MAX_VERTICES
+
+        assert MAX_VERTICES == 100000

+ 0 - 4
demo-video/.gitignore

@@ -1,4 +0,0 @@
-node_modules/
-output/
-*.webm
-*.mp4

+ 0 - 50
demo-video/README.md

@@ -1,50 +0,0 @@
-# Bambuddy Demo Video Recorder
-
-Automated demo video recording using Playwright.
-
-## Setup
-
-```bash
-cd demo-video
-npm install
-npm run install-browsers
-```
-
-## Recording
-
-### Record with visible browser (recommended for debugging)
-```bash
-npm run record
-```
-
-### Record headless (faster, no window)
-```bash
-npm run record:headless
-```
-
-### Custom URL
-```bash
-DEMO_URL=https://your-bambuddy.example.com npm run record
-```
-
-## Output
-
-Videos are saved to `output/` as `.webm` files.
-
-### Convert to MP4
-```bash
-ffmpeg -i output/video.webm -c:v libx264 -crf 23 demo.mp4
-```
-
-### Convert with better quality
-```bash
-ffmpeg -i output/video.webm -c:v libx264 -crf 18 -preset slow demo.mp4
-```
-
-## Customization
-
-Edit `record-demo.ts` to:
-- Adjust timing (TIMING constants)
-- Add/remove page demonstrations
-- Customize interactions per page
-- Change viewport resolution (CONFIG)

+ 0 - 537
demo-video/package-lock.json

@@ -1,537 +0,0 @@
-{
-  "name": "bambuddy-demo-video",
-  "version": "1.0.0",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {
-    "": {
-      "name": "bambuddy-demo-video",
-      "version": "1.0.0",
-      "dependencies": {
-        "playwright": "^1.40.0",
-        "tsx": "^4.7.0"
-      }
-    },
-    "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
-      "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
-      "cpu": [
-        "ppc64"
-      ],
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
-      "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
-      "cpu": [
-        "arm"
-      ],
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
-      "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
-      "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
-      "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
-      "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
-      "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
-      "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
-      "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
-      "cpu": [
-        "arm"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
-      "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ia32": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
-      "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
-      "cpu": [
-        "ia32"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-loong64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
-      "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
-      "cpu": [
-        "loong64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
-      "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
-      "cpu": [
-        "mips64el"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
-      "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
-      "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-s390x": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
-      "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
-      "cpu": [
-        "s390x"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
-      "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
-      "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
-      "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
-      "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
-      "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openharmony-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
-      "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "openharmony"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/sunos-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
-      "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
-      "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-ia32": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
-      "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
-      "cpu": [
-        "ia32"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
-      "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/esbuild": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
-      "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
-      "hasInstallScript": true,
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.27.2",
-        "@esbuild/android-arm": "0.27.2",
-        "@esbuild/android-arm64": "0.27.2",
-        "@esbuild/android-x64": "0.27.2",
-        "@esbuild/darwin-arm64": "0.27.2",
-        "@esbuild/darwin-x64": "0.27.2",
-        "@esbuild/freebsd-arm64": "0.27.2",
-        "@esbuild/freebsd-x64": "0.27.2",
-        "@esbuild/linux-arm": "0.27.2",
-        "@esbuild/linux-arm64": "0.27.2",
-        "@esbuild/linux-ia32": "0.27.2",
-        "@esbuild/linux-loong64": "0.27.2",
-        "@esbuild/linux-mips64el": "0.27.2",
-        "@esbuild/linux-ppc64": "0.27.2",
-        "@esbuild/linux-riscv64": "0.27.2",
-        "@esbuild/linux-s390x": "0.27.2",
-        "@esbuild/linux-x64": "0.27.2",
-        "@esbuild/netbsd-arm64": "0.27.2",
-        "@esbuild/netbsd-x64": "0.27.2",
-        "@esbuild/openbsd-arm64": "0.27.2",
-        "@esbuild/openbsd-x64": "0.27.2",
-        "@esbuild/openharmony-arm64": "0.27.2",
-        "@esbuild/sunos-x64": "0.27.2",
-        "@esbuild/win32-arm64": "0.27.2",
-        "@esbuild/win32-ia32": "0.27.2",
-        "@esbuild/win32-x64": "0.27.2"
-      }
-    },
-    "node_modules/fsevents": {
-      "version": "2.3.2",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
-      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
-      "hasInstallScript": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-      }
-    },
-    "node_modules/get-tsconfig": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
-      "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
-      "dependencies": {
-        "resolve-pkg-maps": "^1.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
-      }
-    },
-    "node_modules/playwright": {
-      "version": "1.57.0",
-      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
-      "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
-      "dependencies": {
-        "playwright-core": "1.57.0"
-      },
-      "bin": {
-        "playwright": "cli.js"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "optionalDependencies": {
-        "fsevents": "2.3.2"
-      }
-    },
-    "node_modules/playwright-core": {
-      "version": "1.57.0",
-      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
-      "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
-      "bin": {
-        "playwright-core": "cli.js"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/resolve-pkg-maps": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
-      "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
-      "funding": {
-        "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
-      }
-    },
-    "node_modules/tsx": {
-      "version": "4.21.0",
-      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
-      "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
-      "dependencies": {
-        "esbuild": "~0.27.0",
-        "get-tsconfig": "^4.7.5"
-      },
-      "bin": {
-        "tsx": "dist/cli.mjs"
-      },
-      "engines": {
-        "node": ">=18.0.0"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.3"
-      }
-    },
-    "node_modules/tsx/node_modules/fsevents": {
-      "version": "2.3.3",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
-      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
-      "hasInstallScript": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-      }
-    }
-  }
-}

+ 0 - 15
demo-video/package.json

@@ -1,15 +0,0 @@
-{
-  "name": "bambuddy-demo-video",
-  "version": "1.0.0",
-  "description": "Automated demo video recording for Bambuddy",
-  "type": "module",
-  "scripts": {
-    "record": "npx tsx record-demo.ts",
-    "record:headless": "HEADLESS=true npx tsx record-demo.ts",
-    "install-browsers": "npx playwright install chromium"
-  },
-  "dependencies": {
-    "playwright": "^1.40.0",
-    "tsx": "^4.7.0"
-  }
-}

+ 0 - 566
demo-video/record-demo.ts

@@ -1,566 +0,0 @@
-import { chromium, Page, Browser, BrowserContext } from 'playwright';
-import path from 'path';
-import { fileURLToPath } from 'url';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-
-// Configuration
-const CONFIG = {
-  baseUrl: process.env.DEMO_URL || 'http://localhost:8000',
-  headless: process.env.HEADLESS === 'true',
-  slowMo: 50, // Slow down actions for visibility
-  viewportWidth: 1920,
-  viewportHeight: 1080,
-  outputDir: path.join(__dirname, 'output'),
-};
-
-// Timing helpers (in ms)
-const TIMING = {
-  pageLoad: 1500,      // Wait after page navigation
-  shortPause: 500,     // Brief pause between actions
-  mediumPause: 1000,   // Standard pause for visibility
-  longPause: 2000,     // Longer pause for important features
-  modalOpen: 800,      // Wait for modal animations
-  scrollPause: 600,    // Pause after scrolling
-};
-
-async function wait(ms: number): Promise<void> {
-  return new Promise(resolve => setTimeout(resolve, ms));
-}
-
-async function scrollDown(page: Page, pixels: number = 300): Promise<void> {
-  await page.mouse.wheel(0, pixels);
-  await wait(TIMING.scrollPause);
-}
-
-async function scrollToTop(page: Page): Promise<void> {
-  await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
-  await wait(TIMING.scrollPause);
-}
-
-async function hoverElement(page: Page, selector: string): Promise<void> {
-  const element = page.locator(selector).first();
-  if (await element.isVisible()) {
-    await element.hover();
-    await wait(TIMING.shortPause);
-  }
-}
-
-async function clickIfVisible(page: Page, selector: string): Promise<boolean> {
-  const element = page.locator(selector).first();
-  if (await element.isVisible()) {
-    await element.click();
-    return true;
-  }
-  return false;
-}
-
-async function closeModalIfOpen(page: Page): Promise<void> {
-  // Try to close any open modal by pressing Escape
-  await page.keyboard.press('Escape');
-  await wait(TIMING.shortPause);
-}
-
-async function blurSensitiveContent(page: Page): Promise<void> {
-  // Use JavaScript to find and blur email addresses
-  await page.evaluate(() => {
-    // Find all spans and check for email patterns
-    document.querySelectorAll('span').forEach(el => {
-      const text = el.textContent || '';
-      // Check if this specific element (not children) contains an email
-      if (el.childNodes.length === 1 && el.childNodes[0].nodeType === Node.TEXT_NODE) {
-        if (text.includes('@') && text.includes('.')) {
-          (el as HTMLElement).style.filter = 'blur(6px)';
-          (el as HTMLElement).style.userSelect = 'none';
-        }
-      }
-    });
-
-    // Also find "Connected as" text and blur the next sibling span
-    document.querySelectorAll('span').forEach(el => {
-      if (el.textContent?.includes('Connected as')) {
-        const emailSpan = el.querySelector('span');
-        if (emailSpan) {
-          (emailSpan as HTMLElement).style.filter = 'blur(6px)';
-          (emailSpan as HTMLElement).style.userSelect = 'none';
-        }
-      }
-    });
-  });
-}
-
-// ============================================================================
-// Page Scenarios
-// ============================================================================
-
-async function demoPrintersPage(page: Page): Promise<void> {
-  console.log('๐Ÿ“ท Demonstrating Printers page...');
-  await page.goto(CONFIG.baseUrl);
-  await wait(TIMING.pageLoad);
-
-  // Hover over printer cards to show interactions
-  const printerCards = page.locator('.group').filter({ has: page.locator('img') });
-  const cardCount = await printerCards.count();
-  console.log(`   Found ${cardCount} printer cards`);
-
-  for (let i = 0; i < Math.min(cardCount, 2); i++) {
-    const card = printerCards.nth(i);
-    if (await card.isVisible()) {
-      await card.hover();
-      await wait(TIMING.mediumPause);
-
-      // Try clicking on card to expand/show details
-      await card.click();
-      await wait(TIMING.mediumPause);
-    }
-  }
-
-  // Look for AMS section and hover over slots
-  const amsSlots = page.locator('[class*="ams"], [class*="AMS"]').first();
-  if (await amsSlots.isVisible()) {
-    await amsSlots.hover();
-    await wait(TIMING.mediumPause);
-  }
-
-  // Try to open camera modal
-  const cameraIcon = page.locator('svg[class*="lucide-video"], button:has(svg)').first();
-  if (await cameraIcon.isVisible()) {
-    await cameraIcon.click();
-    await wait(TIMING.longPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Try to open MQTT debug modal
-  const debugButton = page.locator('button:has-text("Debug"), button:has-text("MQTT")').first();
-  if (await debugButton.isVisible()) {
-    await debugButton.click();
-    await wait(TIMING.longPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Scroll to show more printers
-  await scrollDown(page, 400);
-  await wait(TIMING.mediumPause);
-  await scrollToTop(page);
-}
-
-async function demoArchivesPage(page: Page): Promise<void> {
-  console.log('๐Ÿ“ Demonstrating Archives page...');
-  await page.goto(`${CONFIG.baseUrl}/archives`);
-  await wait(TIMING.pageLoad);
-
-  // Show view mode toggle (grid/list/calendar)
-  const viewToggle = page.locator('button:has(svg[class*="grid"]), button:has(svg[class*="list"])');
-  if (await viewToggle.first().isVisible()) {
-    await viewToggle.first().click();
-    await wait(TIMING.mediumPause);
-    await viewToggle.first().click(); // Toggle back
-    await wait(TIMING.shortPause);
-  }
-
-  // Use search
-  const searchInput = page.locator('input[placeholder*="Search"], input[type="search"]').first();
-  if (await searchInput.isVisible()) {
-    await searchInput.click();
-    await searchInput.fill('engine');
-    await wait(TIMING.longPause);
-    await searchInput.clear();
-    await wait(TIMING.shortPause);
-  }
-
-  // Show filter dropdowns
-  const filterButtons = page.locator('button:has-text("Printer"), button:has-text("Material"), button:has-text("Filter")');
-  if (await filterButtons.first().isVisible()) {
-    await filterButtons.first().click();
-    await wait(TIMING.mediumPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Right-click to show context menu
-  const archiveCard = page.locator('.group').filter({ has: page.locator('img') }).first();
-  if (await archiveCard.isVisible()) {
-    await archiveCard.click({ button: 'right' });
-    await wait(TIMING.longPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Click on archive to open edit modal
-  if (await archiveCard.isVisible()) {
-    await archiveCard.dblclick();
-    await wait(TIMING.longPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Scroll to show more archives
-  await scrollDown(page, 500);
-  await wait(TIMING.mediumPause);
-  await scrollToTop(page);
-}
-
-async function demoQueuePage(page: Page): Promise<void> {
-  console.log('๐Ÿ“‹ Demonstrating Queue page...');
-  await page.goto(`${CONFIG.baseUrl}/queue`);
-  await wait(TIMING.pageLoad);
-
-  // Show filter dropdowns
-  const printerFilter = page.locator('button:has-text("Printer"), select').first();
-  if (await printerFilter.isVisible()) {
-    await printerFilter.click();
-    await wait(TIMING.mediumPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Show sort controls
-  const sortButton = page.locator('button:has-text("Sort"), button:has(svg[class*="arrow"])').first();
-  if (await sortButton.isVisible()) {
-    await sortButton.click();
-    await wait(TIMING.mediumPause);
-  }
-
-  // Hover over queue items to show drag handles
-  const queueItems = page.locator('[draggable="true"], .group').first();
-  if (await queueItems.isVisible()) {
-    await queueItems.hover();
-    await wait(TIMING.mediumPause);
-  }
-
-  // Scroll through queue
-  await scrollDown(page, 300);
-  await wait(TIMING.mediumPause);
-  await scrollToTop(page);
-}
-
-async function demoStatsPage(page: Page): Promise<void> {
-  console.log('๐Ÿ“Š Demonstrating Stats page...');
-  await page.goto(`${CONFIG.baseUrl}/stats`);
-  await wait(TIMING.pageLoad);
-
-  // Let charts animate
-  await wait(TIMING.longPause);
-
-  // Show export dropdown
-  const exportButton = page.locator('button:has-text("Export"), button:has(svg[class*="download"])').first();
-  if (await exportButton.isVisible()) {
-    await exportButton.click();
-    await wait(TIMING.mediumPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Scroll through stats widgets
-  await scrollDown(page, 400);
-  await wait(TIMING.mediumPause);
-  await scrollDown(page, 400);
-  await wait(TIMING.mediumPause);
-  await scrollDown(page, 400);
-  await wait(TIMING.mediumPause);
-  await scrollToTop(page);
-}
-
-async function demoProfilesPage(page: Page): Promise<void> {
-  console.log('โš™๏ธ Demonstrating Profiles page...');
-
-  // Start blur loop BEFORE navigating
-  let blurring = true;
-  const blurLoop = async () => {
-    while (blurring) {
-      try {
-        await page.evaluate(() => {
-          document.querySelectorAll('span').forEach(el => {
-            if (el.textContent?.includes('Connected as')) {
-              const emailSpan = el.querySelector('span');
-              if (emailSpan) {
-                (emailSpan as HTMLElement).style.filter = 'blur(6px)';
-              }
-            }
-          });
-        });
-      } catch { /* page might be navigating */ }
-      await new Promise(r => setTimeout(r, 30));
-    }
-  };
-
-  // Start blur loop in background
-  const blurPromise = blurLoop();
-
-  await page.goto(`${CONFIG.baseUrl}/profiles`);
-  await wait(TIMING.pageLoad);
-
-  // Show Cloud Profiles section
-  await wait(TIMING.mediumPause);
-
-  // Click on K-Profiles tab if available
-  try {
-    const kProfilesTab = page.locator('button:has-text("K-Profile"), button:has-text("K Profile")').first();
-    if (await kProfilesTab.isVisible({ timeout: 1000 })) {
-      await kProfilesTab.click({ timeout: 2000 });
-      await wait(TIMING.mediumPause);
-      await scrollDown(page, 300);
-      await wait(TIMING.shortPause);
-      await scrollToTop(page);
-    }
-  } catch { /* skip */ }
-
-  // Click back to Cloud Profiles
-  try {
-    const cloudTab = page.locator('button:has-text("Cloud")').first();
-    if (await cloudTab.isVisible({ timeout: 1000 })) {
-      await cloudTab.click({ timeout: 2000 });
-      await wait(TIMING.mediumPause);
-    }
-  } catch { /* skip */ }
-
-  // Show preset filter types (if visible) - use force to bypass overlays
-  const presetFilters = page.locator('button:has-text("Filament"), button:has-text("Process"), button:has-text("Machine")');
-  for (let i = 0; i < 3; i++) {
-    try {
-      const filter = presetFilters.nth(i);
-      if (await filter.isVisible({ timeout: 1000 })) {
-        await filter.click({ force: true, timeout: 2000 });
-        await wait(TIMING.shortPause);
-      }
-    } catch { /* skip if not visible or blocked */ }
-  }
-
-  await scrollDown(page, 300);
-  await wait(TIMING.shortPause);
-  await scrollToTop(page);
-
-  // Stop blur loop
-  blurring = false;
-  await blurPromise;
-}
-
-async function demoMaintenancePage(page: Page): Promise<void> {
-  console.log('๐Ÿ”ง Demonstrating Maintenance page...');
-  await page.goto(`${CONFIG.baseUrl}/maintenance`);
-  await wait(TIMING.pageLoad);
-
-  // Show status tab (default)
-  await wait(TIMING.mediumPause);
-
-  // Expand a printer section if available
-  const expandButton = page.locator('button:has(svg[class*="chevron"])').first();
-  if (await expandButton.isVisible()) {
-    await expandButton.click();
-    await wait(TIMING.mediumPause);
-  }
-
-  // Scroll through status
-  await scrollDown(page, 300);
-  await wait(TIMING.shortPause);
-  await scrollToTop(page);
-
-  // Click Settings tab
-  const settingsTab = page.locator('button:has-text("Settings"), [role="tab"]:has-text("Settings")').first();
-  if (await settingsTab.isVisible()) {
-    await settingsTab.click();
-    await wait(TIMING.mediumPause);
-
-    // Scroll through settings
-    await scrollDown(page, 300);
-    await wait(TIMING.shortPause);
-    await scrollToTop(page);
-  }
-
-  // Go back to Status tab
-  const statusTab = page.locator('button:has-text("Status"), [role="tab"]:has-text("Status")').first();
-  if (await statusTab.isVisible()) {
-    await statusTab.click();
-    await wait(TIMING.shortPause);
-  }
-}
-
-async function demoProjectsPage(page: Page): Promise<void> {
-  console.log('๐Ÿ“‚ Demonstrating Projects page...');
-  await page.goto(`${CONFIG.baseUrl}/projects`);
-  await wait(TIMING.pageLoad);
-
-  // Click through status filter buttons
-  const statusFilters = ['Active', 'Completed', 'Archived', 'All'];
-  for (const status of statusFilters) {
-    const filterBtn = page.locator(`button:has-text("${status}")`).first();
-    if (await filterBtn.isVisible()) {
-      await filterBtn.click();
-      await wait(TIMING.shortPause);
-    }
-  }
-
-  // Click on a project to go to detail page
-  const projectCard = page.locator('.group, [class*="project"]').filter({ has: page.locator('h3, h2') }).first();
-  if (await projectCard.isVisible()) {
-    await projectCard.click();
-    await wait(TIMING.pageLoad);
-
-    // Scroll through project detail
-    await scrollDown(page, 300);
-    await wait(TIMING.mediumPause);
-
-    // Look for tabs in project detail (BOM, Attachments, Prints)
-    const detailTabs = ['BOM', 'Attachments', 'Prints', 'Notes'];
-    for (const tabName of detailTabs) {
-      const tab = page.locator(`button:has-text("${tabName}"), [role="tab"]:has-text("${tabName}")`).first();
-      if (await tab.isVisible()) {
-        await tab.click();
-        await wait(TIMING.mediumPause);
-      }
-    }
-
-    await scrollToTop(page);
-  }
-}
-
-async function demoSettingsPage(page: Page): Promise<void> {
-  console.log('โš™๏ธ Demonstrating Settings page...');
-  await page.goto(`${CONFIG.baseUrl}/settings`);
-  await wait(TIMING.pageLoad);
-
-  // Define the 6 tabs to click through
-  const tabs = ['General', 'Plugs', 'Notifications', 'Filament', 'API', 'Virtual'];
-
-  for (const tabName of tabs) {
-    const tab = page.locator(`button:has-text("${tabName}"), [role="tab"]:has-text("${tabName}")`).first();
-    if (await tab.isVisible()) {
-      await tab.click();
-      await wait(TIMING.mediumPause);
-
-      // Scroll through tab content
-      await scrollDown(page, 300);
-      await wait(TIMING.shortPause);
-      await scrollToTop(page);
-    }
-  }
-
-  // Go back to General tab and show a modal
-  const generalTab = page.locator('button:has-text("General")').first();
-  if (await generalTab.isVisible()) {
-    await generalTab.click();
-    await wait(TIMING.shortPause);
-  }
-
-  // Try to open backup modal
-  const backupButton = page.locator('button:has-text("Backup")').first();
-  if (await backupButton.isVisible()) {
-    await backupButton.click();
-    await wait(TIMING.longPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Go to Plugs tab and show add modal
-  const plugsTab = page.locator('button:has-text("Plugs")').first();
-  if (await plugsTab.isVisible()) {
-    await plugsTab.click();
-    await wait(TIMING.shortPause);
-
-    const addPlugButton = page.locator('button:has-text("Add"), button:has(svg[class*="plus"])').first();
-    if (await addPlugButton.isVisible()) {
-      await addPlugButton.click();
-      await wait(TIMING.longPause);
-      await page.keyboard.press('Escape');
-      await wait(TIMING.shortPause);
-    }
-  }
-
-  // Go to Notifications tab and show add modal
-  const notifTab = page.locator('button:has-text("Notifications")').first();
-  if (await notifTab.isVisible()) {
-    await notifTab.click();
-    await wait(TIMING.shortPause);
-
-    const addNotifButton = page.locator('button:has-text("Add"), button:has(svg[class*="plus"])').first();
-    if (await addNotifButton.isVisible()) {
-      await addNotifButton.click();
-      await wait(TIMING.longPause);
-      await page.keyboard.press('Escape');
-      await wait(TIMING.shortPause);
-    }
-  }
-
-  await scrollToTop(page);
-}
-
-async function demoSystemPage(page: Page): Promise<void> {
-  console.log('๐Ÿ’ป Demonstrating System page...');
-  await page.goto(`${CONFIG.baseUrl}/system`);
-  await wait(TIMING.pageLoad);
-
-  // Show system info
-  await wait(TIMING.mediumPause);
-  await scrollDown(page, 300);
-  await wait(TIMING.shortPause);
-  await scrollToTop(page);
-}
-
-// ============================================================================
-// Main Recording Function
-// ============================================================================
-
-async function recordDemo(): Promise<void> {
-  console.log('๐ŸŽฌ Starting Bambuddy demo recording...');
-  console.log(`   URL: ${CONFIG.baseUrl}`);
-  console.log(`   Resolution: ${CONFIG.viewportWidth}x${CONFIG.viewportHeight}`);
-  console.log(`   Headless: ${CONFIG.headless}`);
-  console.log('');
-
-  const browser: Browser = await chromium.launch({
-    headless: CONFIG.headless,
-    slowMo: CONFIG.slowMo,
-  });
-
-  const context: BrowserContext = await browser.newContext({
-    viewport: {
-      width: CONFIG.viewportWidth,
-      height: CONFIG.viewportHeight,
-    },
-    recordVideo: {
-      dir: CONFIG.outputDir,
-      size: {
-        width: CONFIG.viewportWidth,
-        height: CONFIG.viewportHeight,
-      },
-    },
-  });
-
-  const page: Page = await context.newPage();
-
-  try {
-    // Run through all page demos
-    await demoPrintersPage(page);
-    await demoArchivesPage(page);
-    await demoQueuePage(page);
-    await demoStatsPage(page);
-    await demoProfilesPage(page);
-    await demoMaintenancePage(page);
-    await demoProjectsPage(page);
-    await demoSettingsPage(page);
-    await demoSystemPage(page);
-
-    // Return to home page for closing shot
-    console.log('๐Ÿ  Returning to home page...');
-    await page.goto(CONFIG.baseUrl);
-    await wait(TIMING.longPause);
-
-    console.log('โœ… Demo recording completed!');
-  } catch (error) {
-    console.error('โŒ Error during recording:', error);
-    throw error;
-  } finally {
-    await page.close();
-    await context.close();
-    await browser.close();
-  }
-
-  console.log(`\n๐Ÿ“น Video saved to: ${CONFIG.outputDir}/`);
-  console.log('   (Playwright saves as .webm, convert with ffmpeg if needed)');
-  console.log('   Example: ffmpeg -i video.webm -c:v libx264 demo.mp4');
-}
-
-// Run the recording
-recordDemo().catch(console.error);

+ 48 - 18
deploy/bambuddy.service

@@ -1,31 +1,61 @@
+# BamBuddy Systemd Service Template
+#
+# INSTALLATION:
+# 1. Copy this file to /etc/systemd/system/bambuddy.service
+# 2. Replace placeholders:
+#    - INSTALL_PATH: Where BamBuddy is installed (e.g., /opt/bambuddy)
+#    - SERVICE_USER: User to run as (e.g., bambuddy)
+#    - DATA_DIR: Data directory (e.g., /opt/bambuddy/data)
+#    - LOG_DIR: Log directory (e.g., /opt/bambuddy/logs)
+# 3. Run: sudo systemctl daemon-reload
+# 4. Run: sudo systemctl enable bambuddy
+# 5. Run: sudo systemctl start bambuddy
+#
+# Or use the install script: ./install/install.sh
+#
+
 [Unit]
 [Unit]
-Description=BamBuddy Print Archive
+Description=BamBuddy - Bambu Lab Print Management
+Documentation=https://github.com/maziggy/bambuddy
 After=network.target
 After=network.target
 
 
 [Service]
 [Service]
 Type=simple
 Type=simple
-User=claude
-Group=claude
-WorkingDirectory=/opt/claude/projects/bambuddy
-Environment="PATH=/opt/claude/projects/bambuddy/venv/bin"
+User=SERVICE_USER
+Group=SERVICE_USER
+WorkingDirectory=INSTALL_PATH
+
+# Environment file (optional - created by install script)
+EnvironmentFile=-INSTALL_PATH/.env
+
+# Use virtual environment
+Environment="PATH=INSTALL_PATH/venv/bin:/usr/local/bin:/usr/bin:/bin"
+
+# Server configuration
+ExecStart=INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000}
+
+# Restart policy
+Restart=on-failure
+RestartSec=5
 
 
-# Force kill after 10 seconds if graceful shutdown fails
+# Graceful shutdown
 TimeoutStopSec=10
 TimeoutStopSec=10
 
 
-# Kill any zombie ffmpeg processes before starting/after stopping
-ExecStartPre=-/usr/bin/pkill -9 ffmpeg
-ExecStopPost=-/usr/bin/pkill -9 ffmpeg
+# Kill zombie ffmpeg processes (timelapse processing)
+ExecStartPre=-/usr/bin/pkill -9 -f "ffmpeg.*bambuddy"
+ExecStopPost=-/usr/bin/pkill -9 -f "ffmpeg.*bambuddy"
 
 
-# Ensure directories exist and have correct permissions before starting
-# The + prefix runs the command as root even though User=claude
-ExecStartPre=+/bin/mkdir -p /opt/claude/projects/bambuddy/logs
-ExecStartPre=+/bin/mkdir -p /opt/claude/projects/bambuddy/archive
-ExecStartPre=+/bin/chown -R claude:claude /opt/claude/projects/bambuddy/logs
-ExecStartPre=+/bin/chown -R claude:claude /opt/claude/projects/bambuddy/archive
+# Logging
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=bambuddy
 
 
-ExecStart=/opt/claude/projects/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
-Restart=always
-RestartSec=10
+# Security hardening
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=DATA_DIR LOG_DIR INSTALL_PATH
 
 
 [Install]
 [Install]
 WantedBy=multi-user.target
 WantedBy=multi-user.target

+ 1 - 1
frontend/eslint.config.js

@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
 import { defineConfig, globalIgnores } from 'eslint/config'
 import { defineConfig, globalIgnores } from 'eslint/config'
 
 
 export default defineConfig([
 export default defineConfig([
-  globalIgnores(['dist']),
+  globalIgnores(['dist', 'coverage']),
   {
   {
     files: ['**/*.{ts,tsx}'],
     files: ['**/*.{ts,tsx}'],
     extends: [
     extends: [

+ 1 - 1
frontend/src/__tests__/api/githubBackupApi.test.ts

@@ -2,7 +2,7 @@
  * Tests for the GitHub Backup API client functions.
  * Tests for the GitHub Backup API client functions.
  */
  */
 
 
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect } from 'vitest';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { setupServer } from 'msw/node';
 import { setupServer } from 'msw/node';
 import type {
 import type {

+ 5 - 1
frontend/src/__tests__/components/EditArchiveModal.test.tsx

@@ -38,7 +38,11 @@ describe('EditArchiveModal', () => {
         return HttpResponse.json(mockProjects);
         return HttpResponse.json(mockProjects);
       }),
       }),
       http.get('/api/v1/archives/tags', () => {
       http.get('/api/v1/archives/tags', () => {
-        return HttpResponse.json(['test', 'calibration', 'functional']);
+        return HttpResponse.json([
+          { name: 'test', count: 2 },
+          { name: 'calibration', count: 1 },
+          { name: 'functional', count: 3 },
+        ]);
       }),
       }),
       http.patch('/api/v1/archives/:id', async ({ request }) => {
       http.patch('/api/v1/archives/:id', async ({ request }) => {
         const body = await request.json();
         const body = await request.json();

+ 88 - 0
frontend/src/__tests__/components/NotificationProviderCard.test.tsx

@@ -63,6 +63,14 @@ const createMockProvider = (
   on_ams_temperature_high: false,
   on_ams_temperature_high: false,
   on_ams_ht_humidity_high: false,
   on_ams_ht_humidity_high: false,
   on_ams_ht_temperature_high: false,
   on_ams_ht_temperature_high: false,
+  on_plate_not_empty: true,
+  on_queue_job_added: false,
+  on_queue_job_assigned: false,
+  on_queue_job_started: false,
+  on_queue_job_waiting: true,
+  on_queue_job_skipped: true,
+  on_queue_job_failed: true,
+  on_queue_completed: false,
   quiet_hours_enabled: false,
   quiet_hours_enabled: false,
   quiet_hours_start: null,
   quiet_hours_start: null,
   quiet_hours_end: null,
   quiet_hours_end: null,
@@ -282,3 +290,83 @@ describe('NotificationProviderCard AMS toggles', () => {
     });
     });
   });
   });
 });
 });
+
+describe('NotificationProviderCard Queue notifications', () => {
+  describe('queue job notifications', () => {
+    it('includes on_queue_job_added in provider data', () => {
+      const provider = createMockProvider({ on_queue_job_added: true });
+      expect(provider.on_queue_job_added).toBe(true);
+    });
+
+    it('includes on_queue_job_assigned in provider data', () => {
+      const provider = createMockProvider({ on_queue_job_assigned: true });
+      expect(provider.on_queue_job_assigned).toBe(true);
+    });
+
+    it('includes on_queue_job_started in provider data', () => {
+      const provider = createMockProvider({ on_queue_job_started: true });
+      expect(provider.on_queue_job_started).toBe(true);
+    });
+
+    it('includes on_queue_job_waiting in provider data', () => {
+      const provider = createMockProvider({ on_queue_job_waiting: true });
+      expect(provider.on_queue_job_waiting).toBe(true);
+    });
+
+    it('includes on_queue_job_skipped in provider data', () => {
+      const provider = createMockProvider({ on_queue_job_skipped: true });
+      expect(provider.on_queue_job_skipped).toBe(true);
+    });
+
+    it('includes on_queue_job_failed in provider data', () => {
+      const provider = createMockProvider({ on_queue_job_failed: true });
+      expect(provider.on_queue_job_failed).toBe(true);
+    });
+
+    it('includes on_queue_completed in provider data', () => {
+      const provider = createMockProvider({ on_queue_completed: true });
+      expect(provider.on_queue_completed).toBe(true);
+    });
+  });
+
+  describe('queue notification defaults', () => {
+    it('defaults actionable notifications to true', () => {
+      const provider = createMockProvider();
+      // These should default to true (actionable - user needs to do something)
+      expect(provider.on_queue_job_waiting).toBe(true);
+      expect(provider.on_queue_job_skipped).toBe(true);
+      expect(provider.on_queue_job_failed).toBe(true);
+    });
+
+    it('defaults informational notifications to false', () => {
+      const provider = createMockProvider();
+      // These should default to false (informational only)
+      expect(provider.on_queue_job_added).toBe(false);
+      expect(provider.on_queue_job_assigned).toBe(false);
+      expect(provider.on_queue_job_started).toBe(false);
+      expect(provider.on_queue_completed).toBe(false);
+    });
+  });
+
+  describe('queue notification combinations', () => {
+    it('supports all queue toggles independently', () => {
+      const provider = createMockProvider({
+        on_queue_job_added: true,
+        on_queue_job_assigned: false,
+        on_queue_job_started: true,
+        on_queue_job_waiting: false,
+        on_queue_job_skipped: true,
+        on_queue_job_failed: false,
+        on_queue_completed: true,
+      });
+
+      expect(provider.on_queue_job_added).toBe(true);
+      expect(provider.on_queue_job_assigned).toBe(false);
+      expect(provider.on_queue_job_started).toBe(true);
+      expect(provider.on_queue_job_waiting).toBe(false);
+      expect(provider.on_queue_job_skipped).toBe(true);
+      expect(provider.on_queue_job_failed).toBe(false);
+      expect(provider.on_queue_completed).toBe(true);
+    });
+  });
+});

+ 300 - 0
frontend/src/__tests__/components/TagManagementModal.test.tsx

@@ -0,0 +1,300 @@
+/**
+ * Tests for the TagManagementModal component.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { TagManagementModal } from '../../components/TagManagementModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockTags = [
+  { name: 'functional', count: 5 },
+  { name: 'calibration', count: 3 },
+  { name: 'test', count: 2 },
+  { name: 'art', count: 1 },
+];
+
+describe('TagManagementModal', () => {
+  const mockOnClose = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/archives/tags', () => {
+        return HttpResponse.json(mockTags);
+      }),
+      http.put('/api/v1/archives/tags/:tagName', async () => {
+        return HttpResponse.json({ affected: 2 });
+      }),
+      http.delete('/api/v1/archives/tags/:tagName', () => {
+        return HttpResponse.json({ affected: 1 });
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the modal title', async () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      expect(screen.getByText('Manage Tags')).toBeInTheDocument();
+    });
+
+    it('shows loading state initially', () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      // Should show loading spinner before data loads
+      expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
+    });
+
+    it('displays tags with counts', async () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+        expect(screen.getByText('5')).toBeInTheDocument();
+        expect(screen.getByText('calibration')).toBeInTheDocument();
+        expect(screen.getByText('3')).toBeInTheDocument();
+      });
+    });
+
+    it('shows total tag count and usage', async () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        // 4 tags, 11 total usages
+        expect(screen.getByText(/4 tags across 11 usages/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('search functionality', () => {
+    it('filters tags by search input', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const searchInput = screen.getByPlaceholderText('Search tags...');
+      await user.type(searchInput, 'cal');
+
+      await waitFor(() => {
+        expect(screen.getByText('calibration')).toBeInTheDocument();
+        expect(screen.queryByText('functional')).not.toBeInTheDocument();
+        expect(screen.queryByText('art')).not.toBeInTheDocument();
+      });
+    });
+
+    it('shows no results message when search has no matches', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const searchInput = screen.getByPlaceholderText('Search tags...');
+      await user.type(searchInput, 'nonexistent');
+
+      await waitFor(() => {
+        expect(screen.getByText('No tags match your search')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('sorting', () => {
+    it('sorts by count by default', async () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        const tagElements = screen.getAllByText(/functional|calibration|test|art/);
+        // First should be functional (count 5)
+        expect(tagElements[0]).toHaveTextContent('functional');
+      });
+    });
+
+    it('can sort by name', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const sortSelect = screen.getByDisplayValue('Sort by Count');
+      await user.selectOptions(sortSelect, 'name');
+
+      await waitFor(() => {
+        const tagElements = screen.getAllByText(/functional|calibration|test|art/);
+        // First should be 'art' alphabetically
+        expect(tagElements[0]).toHaveTextContent('art');
+      });
+    });
+  });
+
+  describe('rename functionality', () => {
+    it('enters edit mode when clicking edit button', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      // Find the tag row and click its edit button
+      const tagRow = screen.getByText('functional').closest('div');
+      const editButton = within(tagRow!).getByTitle('Rename tag');
+      await user.click(editButton);
+
+      // Should show input with current value
+      await waitFor(() => {
+        expect(screen.getByDisplayValue('functional')).toBeInTheDocument();
+      });
+    });
+
+    it('submits rename on Enter key', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const tagRow = screen.getByText('functional').closest('div');
+      const editButton = within(tagRow!).getByTitle('Rename tag');
+      await user.click(editButton);
+
+      const input = screen.getByDisplayValue('functional');
+      await user.clear(input);
+      await user.type(input, 'new-name{Enter}');
+
+      // Should show success (mutation called)
+      await waitFor(() => {
+        // After successful rename, edit mode should close
+        expect(screen.queryByDisplayValue('new-name')).not.toBeInTheDocument();
+      });
+    });
+
+    it('cancels edit on Escape key', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const tagRow = screen.getByText('functional').closest('div');
+      const editButton = within(tagRow!).getByTitle('Rename tag');
+      await user.click(editButton);
+
+      const input = screen.getByDisplayValue('functional');
+      await user.type(input, '-modified{Escape}');
+
+      // Should exit edit mode without saving
+      await waitFor(() => {
+        expect(screen.queryByDisplayValue('functional-modified')).not.toBeInTheDocument();
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('delete functionality', () => {
+    it('shows delete confirmation when clicking delete button', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const tagRow = screen.getByText('functional').closest('div');
+      const deleteButton = within(tagRow!).getByTitle('Delete tag');
+      await user.click(deleteButton);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Delete "functional" from 5 archives?/i)).toBeInTheDocument();
+      });
+    });
+
+    it('cancels delete confirmation on X button', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const tagRow = screen.getByText('functional').closest('div');
+      const deleteButton = within(tagRow!).getByTitle('Delete tag');
+      await user.click(deleteButton);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Delete "functional"/i)).toBeInTheDocument();
+      });
+
+      // Find the confirmation row and click the cancel (X) button within it
+      const confirmationText = screen.getByText(/Delete "functional"/i);
+      const confirmationRow = confirmationText.closest('div');
+      // The X button is the last button in the confirmation row
+      const buttons = within(confirmationRow!.parentElement!).getAllByRole('button');
+      const cancelButton = buttons[buttons.length - 1]; // X button is last
+      await user.click(cancelButton);
+
+      await waitFor(() => {
+        // Should return to normal display - the tag name should be visible again
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('modal behavior', () => {
+    it('calls onClose when close button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Manage Tags')).toBeInTheDocument();
+      });
+
+      // Find close button in header (X icon)
+      const headerCloseButton = screen.getAllByRole('button')[0];
+      await user.click(headerCloseButton);
+
+      expect(mockOnClose).toHaveBeenCalledTimes(1);
+    });
+
+    it('calls onClose when Close button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Manage Tags')).toBeInTheDocument();
+      });
+
+      const closeButton = screen.getByRole('button', { name: /close/i });
+      await user.click(closeButton);
+
+      expect(mockOnClose).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('empty state', () => {
+    it('shows empty message when no tags exist', async () => {
+      server.use(
+        http.get('/api/v1/archives/tags', () => {
+          return HttpResponse.json([]);
+        })
+      );
+
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('No tags found')).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 0 - 45
frontend/src/__tests__/components/UploadModal.test.tsx

@@ -10,20 +10,12 @@ import { UploadModal } from '../../components/UploadModal';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import { server } from '../mocks/server';
 
 
-const mockPrinters = [
-  { id: 1, name: 'X1 Carbon', model: 'X1C', serial_number: '123' },
-  { id: 2, name: 'P1S', model: 'P1S', serial_number: '456' },
-];
-
 describe('UploadModal', () => {
 describe('UploadModal', () => {
   const mockOnClose = vi.fn();
   const mockOnClose = vi.fn();
 
 
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.clearAllMocks();
     server.use(
     server.use(
-      http.get('/api/v1/printers/', () => {
-        return HttpResponse.json(mockPrinters);
-      }),
       http.post('/api/v1/archives/upload-bulk', async () => {
       http.post('/api/v1/archives/upload-bulk', async () => {
         return HttpResponse.json({
         return HttpResponse.json({
           uploaded: 1,
           uploaded: 1,
@@ -54,17 +46,6 @@ describe('UploadModal', () => {
       expect(screen.getByRole('button', { name: 'Browse Files' })).toBeInTheDocument();
       expect(screen.getByRole('button', { name: 'Browse Files' })).toBeInTheDocument();
     });
     });
 
 
-    it('renders printer selection dropdown', async () => {
-      render(<UploadModal onClose={mockOnClose} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Associate with printer (optional)')).toBeInTheDocument();
-      });
-
-      const select = screen.getByRole('combobox');
-      expect(select).toBeInTheDocument();
-    });
-
     it('renders Cancel button', () => {
     it('renders Cancel button', () => {
       render(<UploadModal onClose={mockOnClose} />);
       render(<UploadModal onClose={mockOnClose} />);
 
 
@@ -79,32 +60,6 @@ describe('UploadModal', () => {
     });
     });
   });
   });
 
 
-  describe('printer selection', () => {
-    it('shows available printers in dropdown', async () => {
-      render(<UploadModal onClose={mockOnClose} />);
-
-      await waitFor(() => {
-        // Check for printer options in the select
-        expect(screen.getByRole('option', { name: 'No printer' })).toBeInTheDocument();
-        expect(screen.getByRole('option', { name: 'X1 Carbon' })).toBeInTheDocument();
-        expect(screen.getByRole('option', { name: 'P1S' })).toBeInTheDocument();
-      });
-    });
-
-    it('allows selecting a printer', async () => {
-      render(<UploadModal onClose={mockOnClose} />);
-
-      await waitFor(() => {
-        expect(screen.getByRole('option', { name: 'X1 Carbon' })).toBeInTheDocument();
-      });
-
-      const select = screen.getByRole('combobox');
-      fireEvent.change(select, { target: { value: '1' } });
-
-      expect(select).toHaveValue('1');
-    });
-  });
-
   describe('file handling with initialFiles', () => {
   describe('file handling with initialFiles', () => {
     it('shows initial files when provided', () => {
     it('shows initial files when provided', () => {
       const initialFiles = [
       const initialFiles = [

+ 198 - 2
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -347,8 +347,8 @@ describe('FileManagerPage', () => {
       render(<FileManagerPage />);
       render(<FileManagerPage />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        // Sort dropdown should show Date as default option
-        expect(screen.getByDisplayValue('Date')).toBeInTheDocument();
+        // Sort dropdown should show Name as default option (persisted to localStorage)
+        expect(screen.getByDisplayValue('Name')).toBeInTheDocument();
       });
       });
     });
     });
   });
   });
@@ -476,4 +476,200 @@ describe('FileManagerPage', () => {
       });
       });
     });
     });
   });
   });
+
+  describe('STL thumbnail generation', () => {
+    it('shows Generate Thumbnails button', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
+      });
+    });
+
+    it('Generate Thumbnails button has correct title', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        const button = screen.getByTitle('Generate thumbnails for STL files missing them');
+        expect(button).toBeInTheDocument();
+      });
+    });
+
+    it('can click Generate Thumbnails button', async () => {
+      const user = userEvent.setup();
+
+      server.use(
+        http.post('/api/v1/library/generate-stl-thumbnails', () => {
+          return HttpResponse.json({
+            processed: 1,
+            succeeded: 1,
+            failed: 0,
+            results: [{ file_id: 2, success: true }],
+          });
+        })
+      );
+
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
+      });
+
+      const button = screen.getByText('Generate Thumbnails');
+      await user.click(button);
+
+      // Button should work without error
+      await waitFor(() => {
+        expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
+      });
+    });
+
+    it('shows STL file without thumbnail in file list', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        // bracket.stl has no thumbnail_path
+        expect(screen.getByText('bracket.stl')).toBeInTheDocument();
+        expect(screen.getAllByText('STL').length).toBeGreaterThan(0);
+      });
+    });
+  });
+
+  describe('upload modal STL options', () => {
+    it('opens upload modal', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+        expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();
+      });
+    });
+
+    it('shows STL thumbnail option when STL file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      // 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('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+      });
+    });
+
+    it('STL thumbnail checkbox is checked by default', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).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);
+
+      await waitFor(() => {
+        expect(screen.getByText('Generate thumbnails for STL files')).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();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).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);
+
+      await waitFor(() => {
+        expect(screen.getByText('Generate thumbnails for STL files')).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();
+    });
+
+    it('shows STL thumbnail option for ZIP files', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).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);
+
+      // 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();
+      });
+    });
+  });
 });
 });

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

@@ -300,6 +300,7 @@ export interface Archive {
   nozzle_diameter: number | null;
   nozzle_diameter: number | null;
   bed_temperature: number | null;
   bed_temperature: number | null;
   nozzle_temperature: number | null;
   nozzle_temperature: number | null;
+  sliced_for_model: string | null;  // Printer model this file was sliced for
   status: string;
   status: string;
   started_at: string | null;
   started_at: string | null;
   completed_at: string | null;
   completed_at: string | null;
@@ -334,6 +335,11 @@ export interface ArchiveStats {
   total_energy_cost: number;
   total_energy_cost: number;
 }
 }
 
 
+export interface TagInfo {
+  name: string;
+  count: number;
+}
+
 export interface FailureAnalysis {
 export interface FailureAnalysis {
   period_days: number;
   period_days: number;
   total_prints: number;
   total_prints: number;
@@ -842,7 +848,7 @@ export interface SmartPlug {
   name: string;
   name: string;
   plug_type: 'tasmota' | 'homeassistant' | 'mqtt';
   plug_type: 'tasmota' | 'homeassistant' | 'mqtt';
   ip_address: string | null;  // Required for Tasmota
   ip_address: string | null;  // Required for Tasmota
-  ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug")
+  ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer")
   // Home Assistant energy sensor entities (optional)
   // Home Assistant energy sensor entities (optional)
   ha_power_entity: string | null;
   ha_power_entity: string | null;
   ha_energy_today_entity: string | null;
   ha_energy_today_entity: string | null;
@@ -881,8 +887,9 @@ export interface SmartPlug {
   schedule_enabled: boolean;
   schedule_enabled: boolean;
   schedule_on_time: string | null;
   schedule_on_time: string | null;
   schedule_off_time: string | null;
   schedule_off_time: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar: boolean;
   show_in_switchbar: boolean;
+  show_on_printer_card: boolean;  // For scripts: show on printer card
   // Status
   // Status
   last_state: string | null;
   last_state: string | null;
   last_checked: string | null;
   last_checked: string | null;
@@ -933,8 +940,9 @@ export interface SmartPlugCreate {
   schedule_enabled?: boolean;
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
   schedule_off_time?: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar?: boolean;
   show_in_switchbar?: boolean;
+  show_on_printer_card?: boolean;
 }
 }
 
 
 export interface SmartPlugUpdate {
 export interface SmartPlugUpdate {
@@ -978,8 +986,9 @@ export interface SmartPlugUpdate {
   schedule_enabled?: boolean;
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
   schedule_off_time?: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar?: boolean;
   show_in_switchbar?: boolean;
+  show_on_printer_card?: boolean;
 }
 }
 
 
 // Home Assistant entity for smart plug selection
 // Home Assistant entity for smart plug selection
@@ -987,7 +996,7 @@ export interface HAEntity {
   entity_id: string;
   entity_id: string;
   friendly_name: string;
   friendly_name: string;
   state: string | null;
   state: string | null;
-  domain: string;  // "switch", "light", "input_boolean"
+  domain: string;  // "switch", "light", "input_boolean", "script"
 }
 }
 
 
 // Home Assistant sensor entity for energy monitoring
 // Home Assistant sensor entity for energy monitoring
@@ -1048,6 +1057,9 @@ export interface DiscoveredTasmotaDevice {
 export interface PrintQueueItem {
 export interface PrintQueueItem {
   id: number;
   id: number;
   printer_id: number | null;  // null = unassigned
   printer_id: number | null;  // null = unassigned
+  target_model: string | null;  // Target printer model for model-based assignment
+  required_filament_types: string[] | null;  // Required filament types for model-based assignment
+  waiting_reason: string | null;  // Why a model-based job hasn't started yet
   // Either archive_id OR library_file_id must be set (archive created at print start)
   // Either archive_id OR library_file_id must be set (archive created at print start)
   archive_id: number | null;
   archive_id: number | null;
   library_file_id: number | null;
   library_file_id: number | null;
@@ -1080,6 +1092,7 @@ export interface PrintQueueItem {
 
 
 export interface PrintQueueItemCreate {
 export interface PrintQueueItemCreate {
   printer_id?: number | null;  // null = unassigned
   printer_id?: number | null;  // null = unassigned
+  target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
   // Either archive_id OR library_file_id must be provided
   // Either archive_id OR library_file_id must be provided
   archive_id?: number | null;
   archive_id?: number | null;
   library_file_id?: number | null;
   library_file_id?: number | null;
@@ -1100,6 +1113,7 @@ export interface PrintQueueItemCreate {
 
 
 export interface PrintQueueItemUpdate {
 export interface PrintQueueItemUpdate {
   printer_id?: number | null;  // null = unassign
   printer_id?: number | null;  // null = unassign
+  target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
   position?: number;
   position?: number;
   scheduled_time?: string | null;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   require_previous_success?: boolean;
@@ -1259,6 +1273,14 @@ export interface NotificationProvider {
   on_ams_ht_temperature_high: boolean;
   on_ams_ht_temperature_high: boolean;
   // Build plate detection
   // Build plate detection
   on_plate_not_empty: boolean;
   on_plate_not_empty: boolean;
+  // Print queue events
+  on_queue_job_added: boolean;
+  on_queue_job_assigned: boolean;
+  on_queue_job_started: boolean;
+  on_queue_job_waiting: boolean;
+  on_queue_job_skipped: boolean;
+  on_queue_job_failed: boolean;
+  on_queue_completed: boolean;
   // Quiet hours
   // Quiet hours
   quiet_hours_enabled: boolean;
   quiet_hours_enabled: boolean;
   quiet_hours_start: string | null;
   quiet_hours_start: string | null;
@@ -1301,6 +1323,14 @@ export interface NotificationProviderCreate {
   on_ams_ht_temperature_high?: boolean;
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   // Build plate detection
   on_plate_not_empty?: boolean;
   on_plate_not_empty?: boolean;
+  // Print queue events
+  on_queue_job_added?: boolean;
+  on_queue_job_assigned?: boolean;
+  on_queue_job_started?: boolean;
+  on_queue_job_waiting?: boolean;
+  on_queue_job_skipped?: boolean;
+  on_queue_job_failed?: boolean;
+  on_queue_completed?: boolean;
   // Quiet hours
   // Quiet hours
   quiet_hours_enabled?: boolean;
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
   quiet_hours_start?: string | null;
@@ -1336,6 +1366,14 @@ export interface NotificationProviderUpdate {
   on_ams_ht_temperature_high?: boolean;
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   // Build plate detection
   on_plate_not_empty?: boolean;
   on_plate_not_empty?: boolean;
+  // Print queue events
+  on_queue_job_added?: boolean;
+  on_queue_job_assigned?: boolean;
+  on_queue_job_started?: boolean;
+  on_queue_job_waiting?: boolean;
+  on_queue_job_skipped?: boolean;
+  on_queue_job_failed?: boolean;
+  on_queue_completed?: boolean;
   // Quiet hours
   // Quiet hours
   quiet_hours_enabled?: boolean;
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
   quiet_hours_start?: string | null;
@@ -1948,6 +1986,17 @@ export const api = {
   deleteArchive: (id: number) =>
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
   getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
   getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
+  // Tag management
+  getTags: () => request<TagInfo[]>('/archives/tags'),
+  renameTag: (oldName: string, newName: string) =>
+    request<{ affected: number }>(`/archives/tags/${encodeURIComponent(oldName)}`, {
+      method: 'PUT',
+      body: JSON.stringify({ new_name: newName }),
+    }),
+  deleteTag: (name: string) =>
+    request<{ affected: number }>(`/archives/tags/${encodeURIComponent(name)}`, {
+      method: 'DELETE',
+    }),
   recalculateCosts: () =>
   recalculateCosts: () =>
     request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
     request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
   getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
   getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
@@ -2441,6 +2490,7 @@ export const api = {
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
   getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
   getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
+  getScriptPlugsByPrinter: (printerId: number) => request<SmartPlug[]>(`/smart-plugs/by-printer/${printerId}/scripts`),
   createSmartPlug: (data: SmartPlugCreate) =>
   createSmartPlug: (data: SmartPlugCreate) =>
     request<SmartPlug>('/smart-plugs/', {
     request<SmartPlug>('/smart-plugs/', {
       method: 'POST',
       method: 'POST',
@@ -3046,11 +3096,17 @@ export const api = {
     return request<LibraryFileListItem[]>(`/library/files?${params}`);
     return request<LibraryFileListItem[]>(`/library/files?${params}`);
   },
   },
   getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
   getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
-  uploadLibraryFile: async (file: File, folderId?: number | null): Promise<LibraryFileUploadResponse> => {
+  uploadLibraryFile: async (
+    file: File,
+    folderId?: number | null,
+    generateStlThumbnails: boolean = true
+  ): Promise<LibraryFileUploadResponse> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
-    const params = folderId ? `?folder_id=${folderId}` : '';
-    const response = await fetch(`${API_BASE}/library/files${params}`, {
+    const params = new URLSearchParams();
+    if (folderId) params.set('folder_id', String(folderId));
+    params.set('generate_stl_thumbnails', String(generateStlThumbnails));
+    const response = await fetch(`${API_BASE}/library/files?${params}`, {
       method: 'POST',
       method: 'POST',
       body: formData,
       body: formData,
     });
     });
@@ -3064,7 +3120,8 @@ export const api = {
     file: File,
     file: File,
     folderId?: number | null,
     folderId?: number | null,
     preserveStructure: boolean = true,
     preserveStructure: boolean = true,
-    createFolderFromZip: boolean = false
+    createFolderFromZip: boolean = false,
+    generateStlThumbnails: boolean = true
   ): Promise<ZipExtractResponse> => {
   ): Promise<ZipExtractResponse> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
@@ -3072,6 +3129,7 @@ export const api = {
     if (folderId) params.set('folder_id', String(folderId));
     if (folderId) params.set('folder_id', String(folderId));
     params.set('preserve_structure', String(preserveStructure));
     params.set('preserve_structure', String(preserveStructure));
     params.set('create_folder_from_zip', String(createFolderFromZip));
     params.set('create_folder_from_zip', String(createFolderFromZip));
+    params.set('generate_stl_thumbnails', String(generateStlThumbnails));
     const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
     const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
       method: 'POST',
       method: 'POST',
       body: formData,
       body: formData,
@@ -3105,6 +3163,15 @@ export const api = {
       body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }),
       body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }),
     }),
     }),
   getLibraryStats: () => request<LibraryStats>('/library/stats'),
   getLibraryStats: () => request<LibraryStats>('/library/stats'),
+  batchGenerateStlThumbnails: (options: {
+    file_ids?: number[];
+    folder_id?: number;
+    all_missing?: boolean;
+  }) =>
+    request<BatchThumbnailResponse>('/library/generate-stl-thumbnails', {
+      method: 'POST',
+      body: JSON.stringify(options),
+    }),
   addLibraryFilesToQueue: (fileIds: number[]) =>
   addLibraryFilesToQueue: (fileIds: number[]) =>
     request<AddToQueueResponse>('/library/files/add-to-queue', {
     request<AddToQueueResponse>('/library/files/add-to-queue', {
       method: 'POST',
       method: 'POST',
@@ -3429,6 +3496,21 @@ export interface ZipExtractResponse {
   errors: ZipExtractError[];
   errors: ZipExtractError[];
 }
 }
 
 
+// STL Thumbnail Generation types
+export interface BatchThumbnailResult {
+  file_id: number;
+  filename: string;
+  success: boolean;
+  error?: string | null;
+}
+
+export interface BatchThumbnailResponse {
+  processed: number;
+  succeeded: number;
+  failed: number;
+  results: BatchThumbnailResult[];
+}
+
 // Library Queue types
 // Library Queue types
 export interface AddToQueueResult {
 export interface AddToQueueResult {
   file_id: number;
   file_id: number;

+ 33 - 14
frontend/src/components/EditArchiveModal.tsx

@@ -68,30 +68,49 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
     queryFn: () => api.getProjects(),
     queryFn: () => api.getProjects(),
   });
   });
 
 
-  // Get all archives to extract existing tags if not provided
-  const { data: archives } = useQuery({
-    queryKey: ['archives'],
-    queryFn: () => api.getArchives(undefined, 1000, 0),
+  // Fetch all tags using the dedicated API
+  const { data: tagsData } = useQuery({
+    queryKey: ['tags'],
+    queryFn: api.getTags,
     enabled: existingTags.length === 0,
     enabled: existingTags.length === 0,
   });
   });
 
 
-  // Extract unique tags from all archives
+  // Use existing tags prop if provided, otherwise use fetched tags
   const allTags = existingTags.length > 0
   const allTags = existingTags.length > 0
     ? existingTags
     ? existingTags
-    : [...new Set(
-        archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
-      )].sort();
+    : (tagsData?.map(t => t.name) || []);
 
 
   // Get current tags as array
   // Get current tags as array
   const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
   const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
 
 
-  // Filter suggestions based on what's not already added
-  const tagSuggestions = allTags.filter(t => !currentTags.includes(t));
+  // Get the text being typed after the last comma (for autocomplete filtering)
+  const currentInput = tags.includes(',')
+    ? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase()
+    : tags.trim().toLowerCase();
 
 
-  // Add a tag
+  // Filter suggestions: not already added AND matches current input (if any)
+  const tagSuggestions = allTags.filter(t =>
+    !currentTags.includes(t) &&
+    (currentInput === '' || t.toLowerCase().includes(currentInput))
+  );
+
+  // Add a tag (replaces any partial input with the selected tag)
   const addTag = (tag: string) => {
   const addTag = (tag: string) => {
-    if (!currentTags.includes(tag)) {
-      const newTags = [...currentTags, tag].join(', ');
+    // If there's partial input being typed, replace it with the selected tag
+    // Otherwise, just append the tag
+    let baseTags: string[];
+    if (currentInput && !allTags.includes(currentInput)) {
+      // User is typing a partial tag - replace it with the selected one
+      baseTags = tags.includes(',')
+        ? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean)
+        : [];
+    } else {
+      // No partial input or input is already a complete tag - append
+      baseTags = currentTags;
+    }
+
+    if (!baseTags.includes(tag)) {
+      const newTags = [...baseTags, tag].join(', ');
       setTags(newTags);
       setTags(newTags);
     }
     }
     // Clear any pending blur timeout to prevent hiding suggestions
     // Clear any pending blur timeout to prevent hiding suggestions
@@ -342,7 +361,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               {showTagSuggestions && tagSuggestions.length > 0 && (
               {showTagSuggestions && tagSuggestions.length > 0 && (
                 <div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
                 <div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
                   <div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
                   <div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
-                    Existing tags (click to add)
+                    {currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
                   </div>
                   </div>
                   <div className="p-2 flex flex-wrap gap-1.5">
                   <div className="p-2 flex flex-wrap gap-1.5">
                     {tagSuggestions.map((tag) => (
                     {tagSuggestions.map((tag) => (

+ 82 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -398,6 +398,88 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                 </div>
                 </div>
               </div>
               </div>
 
 
+              {/* Print Queue Events */}
+              <div className="space-y-2">
+                <p className="text-xs text-bambu-gray uppercase tracking-wide">Print Queue</p>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">Job Added</p>
+                    <p className="text-xs text-bambu-gray">Job added to queue</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_queue_job_added ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_queue_job_added: checked })}
+                  />
+                </div>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">Job Assigned</p>
+                    <p className="text-xs text-bambu-gray">Model-based job assigned to printer</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_queue_job_assigned ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_queue_job_assigned: checked })}
+                  />
+                </div>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">Job Started</p>
+                    <p className="text-xs text-bambu-gray">Queue job started printing</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_queue_job_started ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_queue_job_started: checked })}
+                  />
+                </div>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">Job Waiting</p>
+                    <p className="text-xs text-bambu-gray">Job waiting for filament</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_queue_job_waiting ?? true}
+                    onChange={(checked) => updateMutation.mutate({ on_queue_job_waiting: checked })}
+                  />
+                </div>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">Job Skipped</p>
+                    <p className="text-xs text-bambu-gray">Job skipped (previous failed)</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_queue_job_skipped ?? true}
+                    onChange={(checked) => updateMutation.mutate({ on_queue_job_skipped: checked })}
+                  />
+                </div>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">Job Failed</p>
+                    <p className="text-xs text-bambu-gray">Job failed to start</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_queue_job_failed ?? true}
+                    onChange={(checked) => updateMutation.mutate({ on_queue_job_failed: checked })}
+                  />
+                </div>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">Queue Complete</p>
+                    <p className="text-xs text-bambu-gray">All queue jobs finished</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_queue_completed ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_queue_completed: checked })}
+                  />
+                </div>
+              </div>
+
               {/* Quiet Hours */}
               {/* Quiet Hours */}
               <div className="space-y-2">
               <div className="space-y-2">
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">

+ 133 - 8
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useMemo } from 'react';
 import { useQueryClient } from '@tanstack/react-query';
 import { useQueryClient } from '@tanstack/react-query';
 import {
 import {
   Printer as PrinterIcon,
   Printer as PrinterIcon,
@@ -9,6 +9,7 @@ import {
   Circle,
   Circle,
   RefreshCw,
   RefreshCw,
   Wand2,
   Wand2,
+  Users,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
 import { getColorName } from '../../utils/colors';
 import { getColorName } from '../../utils/colors';
@@ -16,7 +17,7 @@ import {
   normalizeColorForCompare,
   normalizeColorForCompare,
   colorsAreSimilar,
   colorsAreSimilar,
 } from '../../utils/amsHelpers';
 } from '../../utils/amsHelpers';
-import type { PrinterSelectorProps } from './types';
+import type { PrinterSelectorProps, AssignmentMode } from './types';
 import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
 import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
 
 
@@ -29,6 +30,16 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
   onAutoConfigurePrinter?: (printerId: number) => void;
   onAutoConfigurePrinter?: (printerId: number) => void;
   /** Callback to update printer config */
   /** Callback to update printer config */
   onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
   onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
+  /** Current assignment mode */
+  assignmentMode?: AssignmentMode;
+  /** Handler for assignment mode change */
+  onAssignmentModeChange?: (mode: AssignmentMode) => void;
+  /** Selected target model (when assignmentMode is 'model') */
+  targetModel?: string | null;
+  /** Handler for target model change */
+  onTargetModelChange?: (model: string | null) => void;
+  /** Suggested model from sliced file (for pre-selection) */
+  slicedForModel?: string | null;
 }
 }
 
 
 /**
 /**
@@ -212,9 +223,42 @@ export function PrinterSelector({
   filamentReqs,
   filamentReqs,
   onAutoConfigurePrinter,
   onAutoConfigurePrinter,
   onUpdatePrinterConfig,
   onUpdatePrinterConfig,
+  assignmentMode = 'printer',
+  onAssignmentModeChange,
+  targetModel,
+  onTargetModelChange,
+  slicedForModel,
 }: PrinterSelectorWithMappingProps) {
 }: PrinterSelectorWithMappingProps) {
+  // State for showing all printers vs only matching model
+  const [showAllPrinters, setShowAllPrinters] = useState(false);
+
   // Filter printers based on showInactive flag
   // Filter printers based on showInactive flag
-  const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
+  const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active);
+
+  // Filter by sliced model (only in printer mode, when slicedForModel is set)
+  const displayPrinters = useMemo(() => {
+    if (assignmentMode !== 'printer' || !slicedForModel || showAllPrinters) {
+      return activePrinters;
+    }
+    // Filter to only show printers matching the sliced model
+    const matching = activePrinters.filter((p) => p.model === slicedForModel);
+    // If no matching printers, show all
+    return matching.length > 0 ? matching : activePrinters;
+  }, [activePrinters, assignmentMode, slicedForModel, showAllPrinters]);
+
+  // Check if there are hidden printers due to model filtering
+  const hiddenPrinterCount = activePrinters.length - displayPrinters.length;
+
+  // Get unique models from available printers (for model-based assignment)
+  const uniqueModels = useMemo(() => {
+    const models = activePrinters
+      .map(p => p.model)
+      .filter((m): m is string => Boolean(m));
+    return [...new Set(models)].sort();
+  }, [activePrinters]);
+
+  // Check if model-based assignment is available (need callbacks and multiple printers of same model)
+  const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
 
 
   const showMappingOptions = allowMultiple &&
   const showMappingOptions = allowMultiple &&
     selectedPrinterIds.length > 1 &&
     selectedPrinterIds.length > 1 &&
@@ -285,8 +329,56 @@ export function PrinterSelector({
 
 
   return (
   return (
     <div className="space-y-2 mb-6">
     <div className="space-y-2 mb-6">
-      {/* Multi-select header */}
-      {allowMultiple && displayPrinters.length > 1 && (
+      {/* Assignment mode toggle (model vs specific printer) */}
+      {modelAssignmentAvailable && (
+        <div className="flex gap-2 mb-4">
+          <button
+            type="button"
+            onClick={() => {
+              onAssignmentModeChange!('printer');
+              onTargetModelChange!(null);
+            }}
+            className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
+              assignmentMode === 'printer'
+                ? 'border-bambu-green bg-bambu-green/10 text-white'
+                : 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
+            }`}
+          >
+            <PrinterIcon className="w-4 h-4" />
+            <span className="text-sm">Specific Printer</span>
+          </button>
+          <button
+            type="button"
+            onClick={() => {
+              onAssignmentModeChange!('model');
+              onMultiSelect([]);
+              // Pre-select the sliced-for model if available, otherwise first model
+              const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
+                ? slicedForModel
+                : uniqueModels[0];
+              onTargetModelChange!(defaultModel);
+            }}
+            className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
+              assignmentMode === 'model'
+                ? 'border-bambu-green bg-bambu-green/10 text-white'
+                : 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
+            }`}
+          >
+            <Users className="w-4 h-4" />
+            <span className="text-sm">Any {slicedForModel || 'Model'}</span>
+          </button>
+        </div>
+      )}
+
+      {/* Model info (when in model mode) */}
+      {assignmentMode === 'model' && modelAssignmentAvailable && targetModel && (
+        <p className="text-xs text-bambu-gray mb-4">
+          Scheduler will assign to first available idle {targetModel} printer
+        </p>
+      )}
+
+      {/* Multi-select header (only in printer mode) */}
+      {assignmentMode === 'printer' && allowMultiple && displayPrinters.length > 1 && (
         <div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
         <div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
           <span>
           <span>
             {selectedCount === 0
             {selectedCount === 0
@@ -316,7 +408,8 @@ export function PrinterSelector({
         </div>
         </div>
       )}
       )}
 
 
-      {displayPrinters.map((printer) => {
+      {/* Printer list (only in printer mode) */}
+      {assignmentMode === 'printer' && displayPrinters.map((printer) => {
         const selected = isSelected(printer.id);
         const selected = isSelected(printer.id);
         const mappingResult = getPrinterMappingResult(printer.id);
         const mappingResult = getPrinterMappingResult(printer.id);
         const hasOverride = mappingResult && !mappingResult.config.useDefault;
         const hasOverride = mappingResult && !mappingResult.config.useDefault;
@@ -430,13 +523,45 @@ export function PrinterSelector({
         );
         );
       })}
       })}
 
 
-      {/* Warning when no printer selected */}
-      {selectedCount === 0 && (
+      {/* Show hidden printers toggle */}
+      {assignmentMode === 'printer' && hiddenPrinterCount > 0 && !showAllPrinters && (
+        <button
+          type="button"
+          onClick={() => setShowAllPrinters(true)}
+          className="text-xs text-bambu-gray hover:text-white transition-colors mt-2 flex items-center gap-1"
+        >
+          <AlertTriangle className="w-3 h-3 text-yellow-400" />
+          {hiddenPrinterCount} other printer{hiddenPrinterCount > 1 ? 's' : ''} hidden (different model) โ€”
+          <span className="underline">show all</span>
+        </button>
+      )}
+
+      {/* Show matching only toggle */}
+      {assignmentMode === 'printer' && showAllPrinters && slicedForModel && (
+        <button
+          type="button"
+          onClick={() => setShowAllPrinters(false)}
+          className="text-xs text-bambu-gray hover:text-white transition-colors mt-2"
+        >
+          <span className="underline">Show only {slicedForModel} printers</span>
+        </button>
+      )}
+
+      {/* Warning when no printer selected (only in printer mode) */}
+      {assignmentMode === 'printer' && selectedCount === 0 && (
         <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
         <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
           <AlertCircle className="w-3 h-3" />
           <AlertCircle className="w-3 h-3" />
           Select at least one printer
           Select at least one printer
         </p>
         </p>
       )}
       )}
+
+      {/* Warning when no model selected (only in model mode) */}
+      {assignmentMode === 'model' && !targetModel && (
+        <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
+          <AlertCircle className="w-3 h-3" />
+          Select a target printer model
+        </p>
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 144 - 42
frontend/src/components/PrintModal/index.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useMemo } from 'react';
 import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { X, Printer, Loader2, Calendar, Pencil, AlertCircle } from 'lucide-react';
+import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
 import { Card, CardContent } from '../Card';
 import { Card, CardContent } from '../Card';
@@ -19,6 +19,7 @@ import type {
   PrintOptions,
   PrintOptions,
   ScheduleOptions,
   ScheduleOptions,
   ScheduleType,
   ScheduleType,
+  AssignmentMode,
 } from './types';
 } from './types';
 import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
 import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
 
 
@@ -117,6 +118,23 @@ export function PrintModal({
   // Per-printer override configs (for multi-printer selection)
   // Per-printer override configs (for multi-printer selection)
   const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
   const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
 
 
+  // Assignment mode: 'printer' (specific) or 'model' (any of model)
+  const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
+    // Initialize from queue item if editing with target_model
+    if (mode === 'edit-queue-item' && queueItem?.target_model) {
+      return 'model';
+    }
+    return 'printer';
+  });
+
+  // Target model for model-based assignment
+  const [targetModel, setTargetModel] = useState<string | null>(() => {
+    if (mode === 'edit-queue-item' && queueItem?.target_model) {
+      return queueItem.target_model;
+    }
+    return null;
+  });
+
   // Track initial values for clearing mappings on change (edit mode only)
   // Track initial values for clearing mappings on change (edit mode only)
   const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
   const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
   const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
   const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
@@ -145,6 +163,16 @@ export function PrintModal({
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,
   });
   });
 
 
+  // Fetch archive details to get sliced_for_model
+  const { data: archiveDetails } = useQuery({
+    queryKey: ['archive', archiveId],
+    queryFn: () => api.getArchive(archiveId!),
+    enabled: !!archiveId && !isLibraryFile,
+  });
+
+  // Get sliced_for_model from archive or library file
+  const slicedForModel = archiveDetails?.sliced_for_model || null;
+
   // Fetch plates for archives
   // Fetch plates for archives
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
     queryKey: ['archive-plates', archiveId],
     queryKey: ['archive-plates', archiveId],
@@ -304,14 +332,20 @@ export function PrintModal({
   const handleSubmit = async (e?: React.FormEvent) => {
   const handleSubmit = async (e?: React.FormEvent) => {
     e?.preventDefault();
     e?.preventDefault();
 
 
-    // Validate printer selection
-    if (selectedPrinters.length === 0) {
+    // Validate printer/model selection
+    if (assignmentMode === 'printer' && selectedPrinters.length === 0) {
       showToast('Please select at least one printer', 'error');
       showToast('Please select at least one printer', 'error');
       return;
       return;
     }
     }
+    if (assignmentMode === 'model' && !targetModel) {
+      showToast('Please select a target printer model', 'error');
+      return;
+    }
 
 
     setIsSubmitting(true);
     setIsSubmitting(true);
-    setSubmitProgress({ current: 0, total: selectedPrinters.length });
+    // For model-based assignment, we just make one API call
+    const totalCount = assignmentMode === 'model' ? 1 : selectedPrinters.length;
+    setSubmitProgress({ current: 0, total: totalCount });
 
 
     const results: { success: number; failed: number; errors: string[] } = {
     const results: { success: number; failed: number; errors: string[] } = {
       success: 0,
       success: 0,
@@ -332,15 +366,16 @@ export function PrintModal({
     };
     };
 
 
     // Common queue data for add-to-queue and edit modes
     // Common queue data for add-to-queue and edit modes
-    const getQueueData = (printerId: number): PrintQueueItemCreate => ({
-      printer_id: printerId,
+    const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
+      printer_id: assignmentMode === 'printer' ? printerId : null,
+      target_model: assignmentMode === 'model' ? targetModel : null,
       // Use library_file_id for library files, archive_id for archives
       // Use library_file_id for library files, archive_id for archives
       archive_id: isLibraryFile ? undefined : archiveId,
       archive_id: isLibraryFile ? undefined : archiveId,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
       require_previous_success: scheduleOptions.requirePreviousSuccess,
       require_previous_success: scheduleOptions.requirePreviousSuccess,
       auto_off_after: scheduleOptions.autoOffAfter,
       auto_off_after: scheduleOptions.autoOffAfter,
       manual_start: scheduleOptions.scheduleType === 'manual',
       manual_start: scheduleOptions.scheduleType === 'manual',
-      ams_mapping: getMappingForPrinter(printerId),
+      ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
       plate_id: selectedPlate,
       plate_id: selectedPlate,
       scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
       scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
         ? new Date(scheduleOptions.scheduledTime).toISOString()
         ? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -348,35 +383,24 @@ export function PrintModal({
       ...printOptions,
       ...printOptions,
     });
     });
 
 
-    for (let i = 0; i < selectedPrinters.length; i++) {
-      const printerId = selectedPrinters[i];
-      setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
-
+    // Model-based assignment: single API call
+    if (assignmentMode === 'model') {
+      setSubmitProgress({ current: 1, total: 1 });
       try {
       try {
         if (mode === 'reprint') {
         if (mode === 'reprint') {
-          // Reprint mode - start print immediately
-          const printerMapping = getMappingForPrinter(printerId);
-          if (isLibraryFile) {
-            await api.printLibraryFile(libraryFileId!, printerId, {
-              ams_mapping: printerMapping,
-              ...printOptions,
-            });
-          } else {
-            await api.reprintArchive(archiveId!, printerId, {
-              plate_id: selectedPlate ?? undefined,
-              ams_mapping: printerMapping,
-              ...printOptions,
-            });
-          }
-        } else if (mode === 'edit-queue-item' && i === 0) {
-          // Edit mode - update the original queue item for the first printer
-          const printerMapping = getMappingForPrinter(printerId);
+          // Model-based reprint not supported (need specific printer for immediate print)
+          showToast('Model-based assignment only works with queue mode', 'error');
+          setIsSubmitting(false);
+          return;
+        } else if (mode === 'edit-queue-item') {
+          // Edit mode - update with target_model
           const updateData: PrintQueueItemUpdate = {
           const updateData: PrintQueueItemUpdate = {
-            printer_id: printerId,
+            printer_id: null,
+            target_model: targetModel,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             auto_off_after: scheduleOptions.autoOffAfter,
             auto_off_after: scheduleOptions.autoOffAfter,
             manual_start: scheduleOptions.scheduleType === 'manual',
             manual_start: scheduleOptions.scheduleType === 'manual',
-            ams_mapping: printerMapping,
+            ams_mapping: undefined,
             plate_id: selectedPlate,
             plate_id: selectedPlate,
             scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
             scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
               ? new Date(scheduleOptions.scheduledTime).toISOString()
               ? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -385,14 +409,63 @@ export function PrintModal({
           };
           };
           await updateQueueMutation.mutateAsync(updateData);
           await updateQueueMutation.mutateAsync(updateData);
         } else {
         } else {
-          // Add-to-queue mode OR edit mode with additional printers
-          await addToQueueMutation.mutateAsync(getQueueData(printerId));
+          // Add-to-queue mode with model-based assignment
+          await addToQueueMutation.mutateAsync(getQueueData(null));
         }
         }
         results.success++;
         results.success++;
       } catch (error) {
       } catch (error) {
         results.failed++;
         results.failed++;
-        const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
-        results.errors.push(`${printerName}: ${(error as Error).message}`);
+        results.errors.push((error as Error).message);
+      }
+    } else {
+      // Printer-based assignment: loop through selected printers
+      for (let i = 0; i < selectedPrinters.length; i++) {
+        const printerId = selectedPrinters[i];
+        setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
+
+        try {
+          if (mode === 'reprint') {
+            // Reprint mode - start print immediately
+            const printerMapping = getMappingForPrinter(printerId);
+            if (isLibraryFile) {
+              await api.printLibraryFile(libraryFileId!, printerId, {
+                ams_mapping: printerMapping,
+                ...printOptions,
+              });
+            } else {
+              await api.reprintArchive(archiveId!, printerId, {
+                plate_id: selectedPlate ?? undefined,
+                ams_mapping: printerMapping,
+                ...printOptions,
+              });
+            }
+          } else if (mode === 'edit-queue-item' && i === 0) {
+            // Edit mode - update the original queue item for the first printer
+            const printerMapping = getMappingForPrinter(printerId);
+            const updateData: PrintQueueItemUpdate = {
+              printer_id: printerId,
+              target_model: null,
+              require_previous_success: scheduleOptions.requirePreviousSuccess,
+              auto_off_after: scheduleOptions.autoOffAfter,
+              manual_start: scheduleOptions.scheduleType === 'manual',
+              ams_mapping: printerMapping,
+              plate_id: selectedPlate,
+              scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
+                ? new Date(scheduleOptions.scheduledTime).toISOString()
+                : null,
+              ...printOptions,
+            };
+            await updateQueueMutation.mutateAsync(updateData);
+          } else {
+            // Add-to-queue mode OR edit mode with additional printers
+            await addToQueueMutation.mutateAsync(getQueueData(printerId));
+          }
+          results.success++;
+        } catch (error) {
+          results.failed++;
+          const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
+          results.errors.push(`${printerName}: ${(error as Error).message}`);
+        }
       }
       }
     }
     }
 
 
@@ -400,11 +473,15 @@ export function PrintModal({
 
 
     // Show result toast
     // Show result toast
     if (results.failed === 0) {
     if (results.failed === 0) {
-      const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
-      if (results.success === 1) {
-        showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
+      if (assignmentMode === 'model') {
+        showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
       } else {
       } else {
-        showToast(`Print ${action} ${results.success} printers`);
+        const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
+        if (results.success === 1) {
+          showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
+        } else {
+          showToast(`Print ${action} ${results.success} printers`);
+        }
       }
       }
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       onSuccess?.();
       onSuccess?.();
@@ -422,14 +499,18 @@ export function PrintModal({
   const canSubmit = useMemo(() => {
   const canSubmit = useMemo(() => {
     if (isPending) return false;
     if (isPending) return false;
 
 
-    // Need at least one selected printer
-    if (selectedPrinters.length === 0) return false;
+    // Need valid printer/model selection
+    if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
+    if (assignmentMode === 'model' && !targetModel) return false;
+
+    // Model-based assignment only works in queue modes (not immediate reprint)
+    if (assignmentMode === 'model' && mode === 'reprint') return false;
 
 
     // For multi-plate archive files, need a selected plate (library files skip this)
     // For multi-plate archive files, need a selected plate (library files skip this)
     if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
     if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
 
 
     return true;
     return true;
-  }, [selectedPrinters.length, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
+  }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
 
 
   // Modal title and action button text based on mode
   // Modal title and action button text based on mode
   const getModalConfig = () => {
   const getModalConfig = () => {
@@ -541,8 +622,29 @@ export function PrintModal({
               filamentReqs={effectiveFilamentReqs}
               filamentReqs={effectiveFilamentReqs}
               onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
               onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
               onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
               onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
+              assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
+              onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
+              targetModel={targetModel}
+              onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
+              slicedForModel={slicedForModel}
             />
             />
 
 
+            {/* Compatibility warning when sliced model doesn't match selected printer */}
+            {slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
+              const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
+              if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {
+                return (
+                  <div className="p-3 mb-2 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-center gap-2">
+                    <AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
+                    <span className="text-sm text-yellow-400">
+                      File was sliced for {slicedForModel}, but printing on {selectedPrinter.model}
+                    </span>
+                  </div>
+                );
+              }
+              return null;
+            })()}
+
             {/* Warning when archive data couldn't be loaded */}
             {/* Warning when archive data couldn't be loaded */}
             {archiveDataMissing && (
             {archiveDataMissing && (
               <div className="flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm">
               <div className="flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm">
@@ -565,7 +667,7 @@ export function PrintModal({
             )}
             )}
 
 
             {/* Print options */}
             {/* Print options */}
-            {(mode === 'reprint' || effectivePrinterCount > 0) && (
+            {(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
               <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
               <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
             )}
             )}
 
 

+ 17 - 0
frontend/src/components/PrintModal/types.ts

@@ -104,6 +104,13 @@ export interface PlatesResponse {
   plates: PlateInfo[];
   plates: PlateInfo[];
 }
 }
 
 
+/**
+ * Assignment mode for queue items.
+ * - 'printer': Assign to specific printer(s)
+ * - 'model': Assign to any printer of a specific model (load balancing)
+ */
+export type AssignmentMode = 'printer' | 'model';
+
 /**
 /**
  * Props for the PrinterSelector component.
  * Props for the PrinterSelector component.
  */
  */
@@ -115,6 +122,16 @@ export interface PrinterSelectorProps {
   allowMultiple?: boolean;
   allowMultiple?: boolean;
   /** Show inactive printers (for edit mode where original assignment may be inactive) */
   /** Show inactive printers (for edit mode where original assignment may be inactive) */
   showInactive?: boolean;
   showInactive?: boolean;
+  /** Current assignment mode */
+  assignmentMode?: AssignmentMode;
+  /** Handler for assignment mode change */
+  onAssignmentModeChange?: (mode: AssignmentMode) => void;
+  /** Selected target model (when assignmentMode is 'model') */
+  targetModel?: string | null;
+  /** Handler for target model change */
+  onTargetModelChange?: (model: string | null) => void;
+  /** Suggested model from sliced file (for pre-selection) */
+  slicedForModel?: string | null;
 }
 }
 
 
 /**
 /**

+ 294 - 0
frontend/src/components/TagManagementModal.tsx

@@ -0,0 +1,294 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Tag, Pencil, Trash2, Loader2, Search, Check, AlertTriangle } from 'lucide-react';
+import { api } from '../api/client';
+import type { TagInfo } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+interface TagManagementModalProps {
+  onClose: () => void;
+}
+
+export function TagManagementModal({ onClose }: TagManagementModalProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [search, setSearch] = useState('');
+  const [editingTag, setEditingTag] = useState<string | null>(null);
+  const [editValue, setEditValue] = useState('');
+  const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
+  const [sortBy, setSortBy] = useState<'count' | 'name'>('count');
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        if (editingTag) {
+          setEditingTag(null);
+        } else if (deleteConfirm) {
+          setDeleteConfirm(null);
+        } else {
+          onClose();
+        }
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose, editingTag, deleteConfirm]);
+
+  const { data: tags, isLoading } = useQuery({
+    queryKey: ['tags'],
+    queryFn: api.getTags,
+  });
+
+  const renameMutation = useMutation({
+    mutationFn: ({ oldName, newName }: { oldName: string; newName: string }) =>
+      api.renameTag(oldName, newName),
+    onSuccess: (data, { oldName, newName }) => {
+      queryClient.invalidateQueries({ queryKey: ['tags'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(`Renamed "${oldName}" to "${newName}" in ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
+      setEditingTag(null);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to rename tag', 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (name: string) => api.deleteTag(name),
+    onSuccess: (data, name) => {
+      queryClient.invalidateQueries({ queryKey: ['tags'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(`Deleted "${name}" from ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
+      setDeleteConfirm(null);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to delete tag', 'error');
+    },
+  });
+
+  const startEdit = (tag: TagInfo) => {
+    setEditingTag(tag.name);
+    setEditValue(tag.name);
+    setDeleteConfirm(null);
+  };
+
+  const cancelEdit = () => {
+    setEditingTag(null);
+    setEditValue('');
+  };
+
+  const submitEdit = () => {
+    if (!editingTag || !editValue.trim()) return;
+    const newName = editValue.trim();
+    if (newName === editingTag) {
+      cancelEdit();
+      return;
+    }
+    renameMutation.mutate({ oldName: editingTag, newName });
+  };
+
+  const handleEditKeyDown = (e: React.KeyboardEvent) => {
+    if (e.key === 'Enter') {
+      e.preventDefault();
+      submitEdit();
+    } else if (e.key === 'Escape') {
+      e.preventDefault();
+      cancelEdit();
+    }
+  };
+
+  const confirmDelete = (name: string) => {
+    setDeleteConfirm(name);
+    setEditingTag(null);
+  };
+
+  const executeDelete = () => {
+    if (deleteConfirm) {
+      deleteMutation.mutate(deleteConfirm);
+    }
+  };
+
+  // Filter and sort tags
+  const filteredTags = tags
+    ?.filter(t => t.name.toLowerCase().includes(search.toLowerCase()))
+    .sort((a, b) => {
+      if (sortBy === 'count') {
+        return b.count - a.count || a.name.localeCompare(b.name);
+      }
+      return a.name.localeCompare(b.name);
+    });
+
+  const totalUsage = tags?.reduce((sum, t) => sum + t.count, 0) || 0;
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <Card className="w-full max-w-lg max-h-[80vh] flex flex-col">
+        <CardContent className="p-0 flex flex-col min-h-0">
+          {/* Header */}
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
+            <div className="flex items-center gap-2">
+              <Tag className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-xl font-semibold text-white">Manage Tags</h2>
+            </div>
+            <button
+              onClick={onClose}
+              className="text-bambu-gray hover:text-white transition-colors"
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Search and sort */}
+          <div className="p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
+            <div className="flex gap-2">
+              <div className="relative flex-1">
+                <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+                <input
+                  type="text"
+                  placeholder="Search tags..."
+                  className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                  value={search}
+                  onChange={(e) => setSearch(e.target.value)}
+                />
+              </div>
+              <select
+                className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                value={sortBy}
+                onChange={(e) => setSortBy(e.target.value as 'count' | 'name')}
+              >
+                <option value="count">Sort by Count</option>
+                <option value="name">Sort by Name</option>
+              </select>
+            </div>
+            {tags && (
+              <p className="text-xs text-bambu-gray mt-2">
+                {tags.length} tag{tags.length !== 1 ? 's' : ''} across {totalUsage} usage{totalUsage !== 1 ? 's' : ''}
+              </p>
+            )}
+          </div>
+
+          {/* Tags list */}
+          <div className="flex-1 overflow-y-auto min-h-0 p-4">
+            {isLoading ? (
+              <div className="flex items-center justify-center py-8">
+                <Loader2 className="w-6 h-6 animate-spin text-bambu-gray" />
+              </div>
+            ) : !filteredTags?.length ? (
+              <div className="text-center py-8 text-bambu-gray">
+                {search ? 'No tags match your search' : 'No tags found'}
+              </div>
+            ) : (
+              <div className="space-y-2">
+                {filteredTags.map((tag) => (
+                  <div
+                    key={tag.name}
+                    className="flex items-center gap-2 p-2 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors group"
+                  >
+                    {editingTag === tag.name ? (
+                      // Edit mode
+                      <div className="flex-1 flex items-center gap-2">
+                        <input
+                          type="text"
+                          className="flex-1 px-2 py-1 bg-bambu-dark-tertiary border border-bambu-green rounded text-white text-sm focus:outline-none"
+                          value={editValue}
+                          onChange={(e) => setEditValue(e.target.value)}
+                          onKeyDown={handleEditKeyDown}
+                          autoFocus
+                        />
+                        <Button
+                          size="sm"
+                          variant="primary"
+                          onClick={submitEdit}
+                          disabled={!editValue.trim() || renameMutation.isPending}
+                          className="p-1.5"
+                        >
+                          {renameMutation.isPending ? (
+                            <Loader2 className="w-4 h-4 animate-spin" />
+                          ) : (
+                            <Check className="w-4 h-4" />
+                          )}
+                        </Button>
+                        <Button
+                          size="sm"
+                          variant="ghost"
+                          onClick={cancelEdit}
+                          className="p-1.5"
+                        >
+                          <X className="w-4 h-4" />
+                        </Button>
+                      </div>
+                    ) : deleteConfirm === tag.name ? (
+                      // Delete confirmation
+                      <div className="flex-1 flex items-center gap-2">
+                        <AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
+                        <span className="text-sm text-bambu-gray-light flex-1">
+                          Delete "{tag.name}" from {tag.count} archive{tag.count !== 1 ? 's' : ''}?
+                        </span>
+                        <Button
+                          size="sm"
+                          variant="danger"
+                          onClick={executeDelete}
+                          disabled={deleteMutation.isPending}
+                          className="p-1.5"
+                        >
+                          {deleteMutation.isPending ? (
+                            <Loader2 className="w-4 h-4 animate-spin" />
+                          ) : (
+                            <Trash2 className="w-4 h-4" />
+                          )}
+                        </Button>
+                        <Button
+                          size="sm"
+                          variant="ghost"
+                          onClick={() => setDeleteConfirm(null)}
+                          className="p-1.5"
+                        >
+                          <X className="w-4 h-4" />
+                        </Button>
+                      </div>
+                    ) : (
+                      // Normal display
+                      <>
+                        <Tag className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                        <span className="text-white flex-1 truncate">{tag.name}</span>
+                        <span className="px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray text-xs">
+                          {tag.count}
+                        </span>
+                        <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+                          <button
+                            onClick={() => startEdit(tag)}
+                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+                            title="Rename tag"
+                          >
+                            <Pencil className="w-4 h-4" />
+                          </button>
+                          <button
+                            onClick={() => confirmDelete(tag.name)}
+                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
+                            title="Delete tag"
+                          >
+                            <Trash2 className="w-4 h-4" />
+                          </button>
+                        </div>
+                      </>
+                    )}
+                  </div>
+                ))}
+              </div>
+            )}
+          </div>
+
+          {/* Footer */}
+          <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
+            <Button variant="secondary" onClick={onClose} className="flex-1">
+              Close
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 6 - 27
frontend/src/components/UploadModal.tsx

@@ -1,5 +1,5 @@
 import { useState, useCallback, useRef, useEffect } from 'react';
 import { useState, useCallback, useRef, useEffect } from 'react';
-import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
 import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
 import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { BulkUploadResult } from '../api/client';
 import type { BulkUploadResult } from '../api/client';
@@ -27,7 +27,6 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
     initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || []
     initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || []
   );
   );
   const [isDragging, setIsDragging] = useState(false);
   const [isDragging, setIsDragging] = useState(false);
-  const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
   const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
   const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
 
 
   // Close on Escape key
   // Close on Escape key
@@ -39,14 +38,9 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
     return () => window.removeEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
   }, [onClose]);
   }, [onClose]);
 
 
-  const { data: printers } = useQuery({
-    queryKey: ['printers'],
-    queryFn: api.getPrinters,
-  });
-
   const uploadMutation = useMutation({
   const uploadMutation = useMutation({
     mutationFn: (filesToUpload: File[]) =>
     mutationFn: (filesToUpload: File[]) =>
-      api.uploadArchivesBulk(filesToUpload, selectedPrinter),
+      api.uploadArchivesBulk(filesToUpload),
     onSuccess: (result) => {
     onSuccess: (result) => {
       setUploadResult(result);
       setUploadResult(result);
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['archives'] });
@@ -200,26 +194,11 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
             </div>
             </div>
           </div>
           </div>
 
 
-          {/* Optional Printer Selection */}
+          {/* Info about printer model extraction */}
           <div className="px-4 pb-4">
           <div className="px-4 pb-4">
-            <label className="block text-sm text-bambu-gray mb-2">
-              Associate with printer (optional)
-            </label>
-            <select
-              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-              value={selectedPrinter || ''}
-              onChange={(e) =>
-                setSelectedPrinter(e.target.value ? Number(e.target.value) : undefined)
-              }
-              disabled={isUploading}
-            >
-              <option value="">No printer</option>
-              {printers?.map((p) => (
-                <option key={p.id} value={p.id}>
-                  {p.name}
-                </option>
-              ))}
-            </select>
+            <p className="text-xs text-bambu-gray">
+              The printer model will be automatically extracted from the 3MF file metadata.
+            </p>
           </div>
           </div>
 
 
           {/* File List */}
           {/* File List */}

+ 36 - 4
frontend/src/pages/ArchivesPage.tsx

@@ -43,6 +43,7 @@ import {
   FolderKanban,
   FolderKanban,
   ChevronLeft,
   ChevronLeft,
   ChevronRight,
   ChevronRight,
+  Settings,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
 import { openInSlicer } from '../utils/slicer';
@@ -66,6 +67,7 @@ import { ProjectPageModal } from '../components/ProjectPageModal';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
+import { TagManagementModal } from '../components/TagManagementModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
 function formatFileSize(bytes: number): string {
 function formatFileSize(bytes: number): string {
@@ -797,6 +799,12 @@ function ArchiveCard({
               {archive.object_count} object{archive.object_count > 1 ? 's' : ''}
               {archive.object_count} object{archive.object_count > 1 ? 's' : ''}
             </div>
             </div>
           )}
           )}
+          {archive.sliced_for_model && (
+            <div className="flex items-center gap-1.5 text-bambu-gray" title={`Sliced for ${archive.sliced_for_model}`}>
+              <Printer className="w-3 h-3" />
+              {archive.sliced_for_model}
+            </div>
+          )}
           {archive.filament_type && (
           {archive.filament_type && (
             <div className="flex items-center gap-1.5 col-span-2">
             <div className="flex items-center gap-1.5 col-span-2">
               <span className="text-bambu-gray text-xs">{archive.filament_type}</span>
               <span className="text-bambu-gray text-xs">{archive.filament_type}</span>
@@ -1612,9 +1620,20 @@ function ArchiveListRow({
               </Link>
               </Link>
             )}
             )}
           </div>
           </div>
-          {archive.filament_type && (
+          {(archive.filament_type || archive.sliced_for_model) && (
             <div className="flex items-center gap-1.5 mt-0.5">
             <div className="flex items-center gap-1.5 mt-0.5">
-              <span className="text-xs text-bambu-gray">{archive.filament_type}</span>
+              {archive.sliced_for_model && (
+                <span className="text-xs text-bambu-gray flex items-center gap-1" title={`Sliced for ${archive.sliced_for_model}`}>
+                  <Printer className="w-2.5 h-2.5" />
+                  {archive.sliced_for_model}
+                </span>
+              )}
+              {archive.sliced_for_model && archive.filament_type && (
+                <span className="text-bambu-gray/50">ยท</span>
+              )}
+              {archive.filament_type && (
+                <span className="text-xs text-bambu-gray">{archive.filament_type}</span>
+              )}
               {archive.filament_color && (
               {archive.filament_color && (
                 <div className="flex items-center gap-0.5 flex-wrap">
                 <div className="flex items-center gap-0.5 flex-wrap">
                   {archive.filament_color.split(',').map((color, i) => (
                   {archive.filament_color.split(',').map((color, i) => (
@@ -1989,6 +2008,7 @@ export function ArchivesPage() {
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [showCompareModal, setShowCompareModal] = useState(false);
   const [showCompareModal, setShowCompareModal] = useState(false);
+  const [showTagManagement, setShowTagManagement] = useState(false);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
 
 
   // Clear highlight after 5 seconds and scroll to highlighted element
   // Clear highlight after 5 seconds and scroll to highlighted element
@@ -2620,6 +2640,13 @@ export function ArchivesPage() {
                     </option>
                     </option>
                   ))}
                   ))}
                 </select>
                 </select>
+                <button
+                  onClick={() => setShowTagManagement(true)}
+                  className="p-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-green transition-colors"
+                  title="Manage Tags"
+                >
+                  <Settings className="w-4 h-4" />
+                </button>
               </div>
               </div>
             )}
             )}
             <div className="flex items-center gap-2 flex-shrink-0">
             <div className="flex items-center gap-2 flex-shrink-0">
@@ -2755,7 +2782,7 @@ export function ArchivesPage() {
             <ArchiveCard
             <ArchiveCard
               key={archive.id}
               key={archive.id}
               archive={archive}
               archive={archive}
-              printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
+              printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
               isSelected={selectedIds.has(archive.id)}
               isSelected={selectedIds.has(archive.id)}
               onSelect={toggleSelect}
               onSelect={toggleSelect}
               selectionMode={selectionMode}
               selectionMode={selectionMode}
@@ -2782,7 +2809,7 @@ export function ArchivesPage() {
               <ArchiveListRow
               <ArchiveListRow
                 key={archive.id}
                 key={archive.id}
                 archive={archive}
                 archive={archive}
-                printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
+                printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
                 isSelected={selectedIds.has(archive.id)}
                 isSelected={selectedIds.has(archive.id)}
                 onSelect={toggleSelect}
                 onSelect={toggleSelect}
                 selectionMode={selectionMode}
                 selectionMode={selectionMode}
@@ -2848,6 +2875,11 @@ export function ArchivesPage() {
           }}
           }}
         />
         />
       )}
       )}
+
+      {/* Tag Management Modal */}
+      {showTagManagement && (
+        <TagManagementModal onClose={() => setShowTagManagement(false)} />
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 243 - 79
frontend/src/pages/FileManagerPage.tsx

@@ -35,6 +35,7 @@ import {
   Printer,
   Printer,
   Pencil,
   Pencil,
   Play,
   Play,
+  Image,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type {
 import type {
@@ -49,6 +50,7 @@ import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useIsMobile } from '../hooks/useIsMobile';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 type SortDirection = 'asc' | 'desc';
@@ -427,6 +429,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
   const [isUploading, setIsUploading] = useState(false);
   const [isUploading, setIsUploading] = useState(false);
   const [preserveZipStructure, setPreserveZipStructure] = useState(true);
   const [preserveZipStructure, setPreserveZipStructure] = useState(true);
   const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
   const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
+  const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true);
   const fileInputRef = useRef<HTMLInputElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
 
   const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
   const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -466,6 +469,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
   };
   };
 
 
   const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
   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 handleUpload = async () => {
   const handleUpload = async () => {
     if (files.length === 0) return;
     if (files.length === 0) return;
@@ -482,7 +486,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
       try {
       try {
         if (files[i].isZip) {
         if (files[i].isZip) {
           // Extract ZIP file
           // Extract ZIP file
-          const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip);
+          const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails);
           setFiles((prev) =>
           setFiles((prev) =>
             prev.map((f, idx) =>
             prev.map((f, idx) =>
               idx === i
               idx === i
@@ -497,7 +501,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
           );
           );
         } else {
         } else {
           // Regular file upload
           // Regular file upload
-          await api.uploadLibraryFile(files[i].file, folderId);
+          await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails);
           setFiles((prev) =>
           setFiles((prev) =>
             prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
             prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
           );
           );
@@ -596,6 +600,32 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
             </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">
+              <div className="flex items-start gap-3">
+                <Image className="w-5 h-5 text-bambu-green mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-bambu-green font-medium">STL thumbnail generation</p>
+                  <p className="text-xs text-bambu-green/70 mt-1">
+                    {hasZipFiles && !hasStlFiles
+                      ? 'ZIP files may contain STL files. Thumbnails can be generated during extraction.'
+                      : 'Thumbnails can be generated for STL files. Large models may take longer to process.'}
+                  </p>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={generateStlThumbnails}
+                      onChange={(e) => setGenerateStlThumbnails(e.target.checked)}
+                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-white">Generate thumbnails for STL files</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+          )}
+
           {/* File List */}
           {/* File List */}
           {files.length > 0 && (
           {files.length > 0 && (
             <div className="max-h-48 overflow-y-auto space-y-2">
             <div className="max-h-48 overflow-y-auto space-y-2">
@@ -826,15 +856,18 @@ function isSlicedFilename(filename: string): boolean {
 interface FileCardProps {
 interface FileCardProps {
   file: LibraryFileListItem;
   file: LibraryFileListItem;
   isSelected: boolean;
   isSelected: boolean;
+  isMobile: boolean;
   onSelect: (id: number) => void;
   onSelect: (id: number) => void;
   onDelete: (id: number) => void;
   onDelete: (id: number) => void;
   onDownload: (id: number) => void;
   onDownload: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
+  onGenerateThumbnail?: (file: LibraryFileListItem) => void;
+  thumbnailVersion?: number;
 }
 }
 
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
 
 
   return (
   return (
@@ -850,7 +883,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
       <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden">
       <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden">
         {file.thumbnail_path ? (
         {file.thumbnail_path ? (
           <img
           <img
-            src={api.getLibraryFileThumbnailUrl(file.id)}
+            src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersion ? `?v=${thumbnailVersion}` : ''}`}
             alt={file.filename}
             alt={file.filename}
             className="w-full h-full object-cover"
             className="w-full h-full object-cover"
           />
           />
@@ -890,7 +923,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
       </div>
       </div>
 
 
       {/* Actions - always visible on mobile, hover on desktop */}
       {/* Actions - always visible on mobile, hover on desktop */}
-      <div className="absolute bottom-2 right-2 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
+      <div className={`absolute bottom-2 right-2 transition-opacity ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
         <button
         <button
           onClick={() => setShowActions(!showActions)}
           onClick={() => setShowActions(!showActions)}
           className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
           className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
@@ -935,6 +968,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
                   Rename
                   Rename
                 </button>
                 </button>
               )}
               )}
+              {onGenerateThumbnail && file.file_type === 'stl' && (
+                <button
+                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
+                  onClick={() => { onGenerateThumbnail(file); setShowActions(false); }}
+                >
+                  <Image className="w-3.5 h-3.5" />
+                  Generate Thumbnail
+                </button>
+              )}
               <button
               <button
                 className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
                 className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
                 onClick={() => { onDelete(file.id); setShowActions(false); }}
                 onClick={() => { onDelete(file.id); setShowActions(false); }}
@@ -951,7 +993,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
       <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
       <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
         isSelected
         isSelected
           ? 'bg-bambu-green border-bambu-green'
           ? 'bg-bambu-green border-bambu-green'
-          : 'border-white/30 bg-black/30 opacity-100 md:opacity-0 md:group-hover:opacity-100'
+          : `border-white/30 bg-black/30 ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`
       }`}>
       }`}>
         {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
         {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
       </div>
       </div>
@@ -979,6 +1021,7 @@ export function FileManagerPage() {
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
   const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
+  const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
   });
@@ -1032,11 +1075,20 @@ export function FileManagerPage() {
     };
     };
   }, [isResizing, sidebarWidth]);
   }, [isResizing, sidebarWidth]);
 
 
-  // Filter and sort state
+  // Filter and sort state (persist sort preferences to localStorage)
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
   const [filterType, setFilterType] = useState<string>('all');
   const [filterType, setFilterType] = useState<string>('all');
-  const [sortField, setSortField] = useState<SortField>('date');
-  const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
+  const [sortField, setSortField] = useState<SortField>(() => {
+    const saved = localStorage.getItem('library-sort-field');
+    return (saved as SortField) || 'name';
+  });
+  const [sortDirection, setSortDirection] = useState<SortDirection>(() => {
+    const saved = localStorage.getItem('library-sort-direction');
+    return (saved as SortDirection) || 'asc';
+  });
+
+  // Mobile detection for touch-friendly UI
+  const isMobile = useIsMobile();
 
 
   // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
   // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
   useEffect(() => {
   useEffect(() => {
@@ -1272,6 +1324,52 @@ export function FileManagerPage() {
     },
     },
   });
   });
 
 
+  const batchThumbnailMutation = useMutation({
+    mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      // Update thumbnail versions for cache busting
+      if (result.succeeded > 0) {
+        const now = Date.now();
+        const newVersions: Record<number, number> = {};
+        result.results.forEach((r) => {
+          if (r.success) {
+            newVersions[r.file_id] = now;
+          }
+        });
+        setThumbnailVersions((prev) => ({ ...prev, ...newVersions }));
+      }
+      if (result.succeeded > 0 && result.failed === 0) {
+        showToast(`Generated ${result.succeeded} thumbnail${result.succeeded > 1 ? 's' : ''}`, 'success');
+      } else if (result.succeeded > 0 && result.failed > 0) {
+        showToast(`Generated ${result.succeeded} thumbnail${result.succeeded > 1 ? 's' : ''}, ${result.failed} failed`, 'success');
+      } else if (result.processed === 0) {
+        showToast('No STL files missing thumbnails', 'info');
+      } else {
+        showToast(`Failed to generate thumbnails: ${result.results[0]?.error || 'Unknown error'}`, 'error');
+      }
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const singleThumbnailMutation = useMutation({
+    mutationFn: (fileId: number) => api.batchGenerateStlThumbnails({ file_ids: [fileId] }),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      // Update thumbnail version for cache busting
+      if (result.succeeded > 0) {
+        const fileId = result.results[0]?.file_id;
+        if (fileId) {
+          setThumbnailVersions((prev) => ({ ...prev, [fileId]: Date.now() }));
+        }
+        showToast('Thumbnail generated', 'success');
+      } else {
+        showToast(`Failed to generate thumbnail: ${result.results[0]?.error || 'Unknown error'}`, 'error');
+      }
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
   // Helper to check if a file is sliced (printable)
   // Helper to check if a file is sliced (printable)
   const isSlicedFile = useCallback((filename: string) => {
   const isSlicedFile = useCallback((filename: string) => {
     const lower = filename.toLowerCase();
     const lower = filename.toLowerCase();
@@ -1333,7 +1431,7 @@ export function FileManagerPage() {
   const isLoading = foldersLoading || filesLoading;
   const isLoading = foldersLoading || filesLoading;
 
 
   return (
   return (
-    <div className="p-4 md:p-8 h-[calc(100vh-64px)] flex flex-col">
+    <div className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col">
       {/* Header */}
       {/* Header */}
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
         <div>
         <div>
@@ -1369,6 +1467,19 @@ export function FileManagerPage() {
               <List className="w-4 h-4" />
               <List className="w-4 h-4" />
             </button>
             </button>
           </div>
           </div>
+          <Button
+            variant="secondary"
+            onClick={() => batchThumbnailMutation.mutate()}
+            disabled={batchThumbnailMutation.isPending}
+            title="Generate thumbnails for STL files missing them"
+          >
+            {batchThumbnailMutation.isPending ? (
+              <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+            ) : (
+              <Image className="w-4 h-4 mr-2" />
+            )}
+            Generate Thumbnails
+          </Button>
           <Button variant="secondary" onClick={() => setShowNewFolderModal(true)}>
           <Button variant="secondary" onClick={() => setShowNewFolderModal(true)}>
             <FolderPlus className="w-4 h-4 mr-2" />
             <FolderPlus className="w-4 h-4 mr-2" />
             New Folder
             New Folder
@@ -1396,7 +1507,7 @@ export function FileManagerPage() {
 
 
       {/* Stats bar */}
       {/* Stats bar */}
       {stats && (
       {stats && (
-        <div className="flex items-center 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-card rounded-lg border border-bambu-dark-tertiary">
           <div className="flex items-center gap-2 text-sm">
           <div className="flex items-center gap-2 text-sm">
             <File className="w-4 h-4 text-bambu-green" />
             <File className="w-4 h-4 text-bambu-green" />
             <span className="text-bambu-gray">Files:</span>
             <span className="text-bambu-gray">Files:</span>
@@ -1412,7 +1523,7 @@ export function FileManagerPage() {
             <span className="text-bambu-gray">Size:</span>
             <span className="text-bambu-gray">Size:</span>
             <span className="text-white font-medium">{formatFileSize(stats.total_size_bytes)}</span>
             <span className="text-white font-medium">{formatFileSize(stats.total_size_bytes)}</span>
           </div>
           </div>
-          <div className="flex items-center gap-2 text-sm ml-auto">
+          <div className="flex items-center gap-2 text-sm sm:ml-auto">
             <span className="text-bambu-gray">Free:</span>
             <span className="text-bambu-gray">Free:</span>
             <span className={`font-medium ${isDiskSpaceLow ? 'text-amber-500' : 'text-white'}`}>
             <span className={`font-medium ${isDiskSpaceLow ? 'text-amber-500' : 'text-white'}`}>
               {formatFileSize(stats.disk_free_bytes)}
               {formatFileSize(stats.disk_free_bytes)}
@@ -1422,11 +1533,40 @@ export function FileManagerPage() {
       )}
       )}
 
 
       {/* Main content */}
       {/* Main content */}
-      <div className="flex-1 flex gap-6 min-h-0">
-        {/* Folder sidebar - resizable */}
+      <div className="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-6 min-h-0">
+        {/* Mobile folder selector */}
+        <div className="lg:hidden">
+          <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"
+          >
+            <option value="">๐Ÿ“ All Files</option>
+            {folders && (() => {
+              // Flatten folder tree for mobile selector
+              const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number; name: string; fileCount: number; depth: number }[] => {
+                const result: { id: number; name: string; fileCount: number; depth: number }[] = [];
+                for (const item of items) {
+                  result.push({ id: item.id, name: item.name, fileCount: item.file_count, depth });
+                  if (item.children.length > 0) {
+                    result.push(...flattenFolders(item.children, depth + 1));
+                  }
+                }
+                return result;
+              };
+              return flattenFolders(folders).map((folder) => (
+                <option key={folder.id} value={folder.id}>
+                  {'โ”‚ '.repeat(folder.depth)}๐Ÿ“‚ {folder.name} {folder.fileCount > 0 ? `(${folder.fileCount})` : ''}
+                </option>
+              ));
+            })()}
+          </select>
+        </div>
+
+        {/* Folder sidebar - resizable, hidden on mobile */}
         <div
         <div
           ref={sidebarRef}
           ref={sidebarRef}
-          className="flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex flex-col relative"
+          className="hidden lg:flex flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex-col relative"
           style={{ width: `${sidebarWidth}px` }}
           style={{ width: `${sidebarWidth}px` }}
         >
         >
           {/* Resize handle - drag to resize, double-click to reset */}
           {/* Resize handle - drag to resize, double-click to reset */}
@@ -1500,12 +1640,12 @@ export function FileManagerPage() {
         </div>
         </div>
 
 
         {/* Files area */}
         {/* Files area */}
-        <div className="flex-1 flex flex-col min-w-0">
-          {/* Search, Filter, Sort toolbar */}
+        <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 && (
           {files && files.length > 0 && (
-            <div className="flex items-center gap-3 mb-4 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
+            <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">
               {/* Search */}
               {/* Search */}
-              <div className="relative flex-1 max-w-xs">
+              <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" />
                 <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
                 <input
                 <input
                   type="text"
                   type="text"
@@ -1518,7 +1658,7 @@ export function FileManagerPage() {
 
 
               {/* Type filter */}
               {/* Type filter */}
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
-                <Filter className="w-4 h-4 text-bambu-gray" />
+                <Filter className="w-4 h-4 text-bambu-gray hidden sm:block" />
                 <select
                 <select
                   value={filterType}
                   value={filterType}
                   onChange={(e) => setFilterType(e.target.value)}
                   onChange={(e) => setFilterType(e.target.value)}
@@ -1537,17 +1677,25 @@ export function FileManagerPage() {
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 <select
                 <select
                   value={sortField}
                   value={sortField}
-                  onChange={(e) => setSortField(e.target.value as SortField)}
+                  onChange={(e) => {
+                    const newField = e.target.value as SortField;
+                    setSortField(newField);
+                    localStorage.setItem('library-sort-field', newField);
+                  }}
                   className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green"
                   className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green"
                 >
                 >
-                  <option value="date">Date</option>
                   <option value="name">Name</option>
                   <option value="name">Name</option>
+                  <option value="date">Date</option>
                   <option value="size">Size</option>
                   <option value="size">Size</option>
                   <option value="type">Type</option>
                   <option value="type">Type</option>
                   <option value="prints">Prints</option>
                   <option value="prints">Prints</option>
                 </select>
                 </select>
                 <button
                 <button
-                  onClick={() => setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'))}
+                  onClick={() => setSortDirection((d) => {
+                    const newDir = d === 'asc' ? 'desc' : 'asc';
+                    localStorage.setItem('library-sort-direction', newDir);
+                    return newDir;
+                  })}
                   className="p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
                   className="p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
                   title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
                   title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
                 >
                 >
@@ -1561,16 +1709,16 @@ export function FileManagerPage() {
 
 
               {/* Results count */}
               {/* Results count */}
               {(searchQuery || filterType !== 'all') && (
               {(searchQuery || filterType !== 'all') && (
-                <span className="text-sm text-bambu-gray">
+                <span className="text-sm text-bambu-gray hidden sm:inline">
                   {filteredAndSortedFiles.length} of {files.length} files
                   {filteredAndSortedFiles.length} of {files.length} files
                 </span>
                 </span>
               )}
               )}
             </div>
             </div>
           )}
           )}
 
 
-          {/* Selection toolbar */}
+          {/* Selection toolbar - sticky on mobile below search bar */}
           {filteredAndSortedFiles.length > 0 && (
           {filteredAndSortedFiles.length > 0 && (
-            <div className="flex items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
+            <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">
               {/* Select all / Deselect all */}
               {/* Select all / Deselect all */}
               {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
               {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
                 <Button
                 <Button
@@ -1578,8 +1726,8 @@ export function FileManagerPage() {
                   size="sm"
                   size="sm"
                   onClick={handleDeselectAll}
                   onClick={handleDeselectAll}
                 >
                 >
-                  <Square className="w-4 h-4 mr-1" />
-                  Deselect All
+                  <Square className="w-4 h-4 sm:mr-1" />
+                  <span className="hidden sm:inline">Deselect All</span>
                 </Button>
                 </Button>
               ) : (
               ) : (
                 <Button
                 <Button
@@ -1587,8 +1735,8 @@ export function FileManagerPage() {
                   size="sm"
                   size="sm"
                   onClick={handleSelectAll}
                   onClick={handleSelectAll}
                 >
                 >
-                  <CheckSquare className="w-4 h-4 mr-1" />
-                  Select All
+                  <CheckSquare className="w-4 h-4 sm:mr-1" />
+                  <span className="hidden sm:inline">Select All</span>
                 </Button>
                 </Button>
               )}
               )}
 
 
@@ -1597,57 +1745,60 @@ export function FileManagerPage() {
                   <span className="text-sm text-bambu-gray ml-2">
                   <span className="text-sm text-bambu-gray ml-2">
                     {selectedFiles.length} selected
                     {selectedFiles.length} selected
                   </span>
                   </span>
-                  <div className="flex-1" />
-                  {selectedSlicedFiles.length === 1 && (
+                  <div className="hidden sm:block flex-1" />
+                  <div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
+                    {selectedSlicedFiles.length === 1 && (
+                      <Button
+                        variant="primary"
+                        size="sm"
+                        onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
+                      >
+                        <Play className="w-4 h-4 sm:mr-1" />
+                        <span className="hidden sm:inline">Print</span>
+                      </Button>
+                    )}
+                    {selectedSlicedFiles.length > 0 && (
+                      <Button
+                        variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
+                        size="sm"
+                        onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
+                        disabled={addToQueueMutation.isPending}
+                      >
+                        <Clock className="w-4 h-4 sm:mr-1" />
+                        <span className="hidden sm:inline">{addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}</span>
+                      </Button>
+                    )}
                     <Button
                     <Button
-                      variant="primary"
+                      variant="secondary"
                       size="sm"
                       size="sm"
-                      onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
+                      onClick={() => setShowMoveModal(true)}
                     >
                     >
-                      <Play className="w-4 h-4 mr-1" />
-                      Print
+                      <MoveRight className="w-4 h-4 sm:mr-1" />
+                      <span className="hidden sm:inline">Move</span>
                     </Button>
                     </Button>
-                  )}
-                  {selectedSlicedFiles.length > 0 && (
                     <Button
                     <Button
-                      variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
+                      variant="danger"
                       size="sm"
                       size="sm"
-                      onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
-                      disabled={addToQueueMutation.isPending}
+                      onClick={() => {
+                        if (selectedFiles.length === 1) {
+                          setDeleteConfirm({ type: 'file', id: selectedFiles[0] });
+                        } else {
+                          setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
+                        }
+                      }}
                     >
                     >
-                      <Clock className="w-4 h-4 mr-1" />
-                      {addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}
+                      <Trash2 className="w-4 h-4 sm:mr-1" />
+                      <span className="hidden sm:inline">Delete</span>
                     </Button>
                     </Button>
-                  )}
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={() => setShowMoveModal(true)}
-                  >
-                    <MoveRight className="w-4 h-4 mr-1" />
-                    Move
-                  </Button>
-                  <Button
-                    variant="danger"
-                    size="sm"
-                    onClick={() => {
-                      if (selectedFiles.length === 1) {
-                        setDeleteConfirm({ type: 'file', id: selectedFiles[0] });
-                      } else {
-                        setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
-                      }
-                    }}
-                  >
-                    <Trash2 className="w-4 h-4 mr-1" />
-                    Delete
-                  </Button>
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={handleDeselectAll}
-                  >
-                    Clear
-                  </Button>
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      onClick={handleDeselectAll}
+                    >
+                      <X className="w-4 h-4 sm:mr-1" />
+                      <span className="hidden sm:inline">Clear</span>
+                    </Button>
+                  </div>
                 </>
                 </>
               )}
               )}
             </div>
             </div>
@@ -1693,28 +1844,31 @@ export function FileManagerPage() {
               </Button>
               </Button>
             </div>
             </div>
           ) : viewMode === 'grid' ? (
           ) : viewMode === 'grid' ? (
-            <div className="flex-1 overflow-y-auto">
+            <div className="flex-1 lg:overflow-y-auto">
               <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
               <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
                 {filteredAndSortedFiles.map((file) => (
                 {filteredAndSortedFiles.map((file) => (
                   <FileCard
                   <FileCard
                     key={file.id}
                     key={file.id}
                     file={file}
                     file={file}
                     isSelected={selectedFiles.includes(file.id)}
                     isSelected={selectedFiles.includes(file.id)}
+                    isMobile={isMobile}
                     onSelect={handleFileSelect}
                     onSelect={handleFileSelect}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDownload={handleDownload}
                     onDownload={handleDownload}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
                     onPrint={setPrintFile}
                     onPrint={setPrintFile}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
+                    onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
+                    thumbnailVersion={thumbnailVersions[file.id]}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
             </div>
             </div>
           ) : (
           ) : (
-            <div className="flex-1 overflow-y-auto">
+            <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-card rounded-lg border border-bambu-dark-tertiary overflow-hidden">
-                {/* List header */}
-                <div className="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">
+                {/* 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="w-6" />
                   <div className="w-6" />
                   <div>Name</div>
                   <div>Name</div>
                   <div>Type</div>
                   <div>Type</div>
@@ -1745,7 +1899,7 @@ export function FileManagerPage() {
                         <div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden">
                         <div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden">
                           {file.thumbnail_path ? (
                           {file.thumbnail_path ? (
                             <img
                             <img
-                              src={api.getLibraryFileThumbnailUrl(file.id)}
+                              src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
                               alt=""
                               alt=""
                               className="w-full h-full object-cover"
                               className="w-full h-full object-cover"
                             />
                             />
@@ -1760,7 +1914,7 @@ export function FileManagerPage() {
                           <div className="absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block">
                           <div className="absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block">
                             <div className="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
                             <div className="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
                               <img
                               <img
-                                src={api.getLibraryFileThumbnailUrl(file.id)}
+                                src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
                                 alt={file.filename}
                                 alt={file.filename}
                                 className="w-full h-full object-contain"
                                 className="w-full h-full object-contain"
                               />
                               />
@@ -1822,6 +1976,16 @@ export function FileManagerPage() {
                       >
                       >
                         <Pencil className="w-4 h-4" />
                         <Pencil className="w-4 h-4" />
                       </button>
                       </button>
+                      {file.file_type === 'stl' && (
+                        <button
+                          onClick={() => singleThumbnailMutation.mutate(file.id)}
+                          className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
+                          title="Generate Thumbnail"
+                          disabled={singleThumbnailMutation.isPending}
+                        >
+                          <Image className="w-4 h-4" />
+                        </button>
+                      )}
                       <button
                       <button
                         onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
                         onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
                         className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
                         className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"

+ 41 - 4
frontend/src/pages/PrintersPage.tsx

@@ -1093,6 +1093,12 @@ function PrinterCard({
     queryFn: () => api.getSmartPlugByPrinter(printer.id),
     queryFn: () => api.getSmartPlugByPrinter(printer.id),
   });
   });
 
 
+  // Fetch script plugs for this printer (for multi-device control)
+  const { data: scriptPlugs } = useQuery({
+    queryKey: ['scriptPlugsByPrinter', printer.id],
+    queryFn: () => api.getScriptPlugsByPrinter(printer.id),
+  });
+
   // Fetch smart plug status if plug exists (faster refresh for energy monitoring)
   // Fetch smart plug status if plug exists (faster refresh for energy monitoring)
   const { data: plugStatus } = useQuery({
   const { data: plugStatus } = useQuery({
     queryKey: ['smartPlugStatus', smartPlug?.id],
     queryKey: ['smartPlugStatus', smartPlug?.id],
@@ -1155,6 +1161,15 @@ function PrinterCard({
     },
     },
   });
   });
 
 
+  // Run script mutation
+  const runScriptMutation = useMutation({
+    mutationFn: (scriptId: number) => api.controlSmartPlug(scriptId, 'on'),
+    onSuccess: () => {
+      showToast('Script triggered');
+    },
+    onError: (error: Error) => showToast(error.message || 'Failed to run script', 'error'),
+  });
+
   // Print control mutations
   // Print control mutations
   const stopPrintMutation = useMutation({
   const stopPrintMutation = useMutation({
     mutationFn: () => api.stopPrint(printer.id),
     mutationFn: () => api.stopPrint(printer.id),
@@ -2683,17 +2698,39 @@ function PrinterCard({
                 </button>
                 </button>
               </div>
               </div>
             </div>
             </div>
+
+            {/* Script buttons row */}
+            {scriptPlugs && scriptPlugs.length > 0 && (
+              <div className="flex items-center gap-2 mt-2 pt-2 border-t border-bambu-dark-tertiary/50">
+                <Play className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />
+                <span className="text-xs text-bambu-gray">Scripts:</span>
+                <div className="flex flex-wrap gap-1">
+                  {scriptPlugs.map(script => (
+                    <button
+                      key={script.id}
+                      onClick={() => runScriptMutation.mutate(script.id)}
+                      disabled={runScriptMutation.isPending}
+                      title={`Run ${script.ha_entity_id}`}
+                      className="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 rounded transition-colors flex items-center gap-1"
+                    >
+                      <Play className="w-2.5 h-2.5" />
+                      {script.name}
+                    </button>
+                  ))}
+                </div>
+              </div>
+            )}
           </div>
           </div>
         )}
         )}
 
 
         {/* Connection Info & Actions - hidden in compact mode */}
         {/* Connection Info & Actions - hidden in compact mode */}
         {viewMode === 'expanded' && (
         {viewMode === 'expanded' && (
-          <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-between">
+          <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
             <div className="text-xs text-bambu-gray">
             <div className="text-xs text-bambu-gray">
               <p>{printer.ip_address}</p>
               <p>{printer.ip_address}</p>
               <p className="truncate">{printer.serial_number}</p>
               <p className="truncate">{printer.serial_number}</p>
             </div>
             </div>
-            <div className="flex items-center gap-2">
+            <div className="flex items-center gap-2 flex-wrap">
               {/* Chamber Light Toggle */}
               {/* Chamber Light Toggle */}
               <Button
               <Button
                 variant="secondary"
                 variant="secondary"
@@ -4487,12 +4524,12 @@ export function PrintersPage() {
 
 
   return (
   return (
     <div className="p-4 md:p-8">
     <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>
         <div>
           <h1 className="text-2xl font-bold text-white">Printers</h1>
           <h1 className="text-2xl font-bold text-white">Printers</h1>
           <StatusSummaryBar printers={printers} />
           <StatusSummaryBar printers={printers} />
         </div>
         </div>
-        <div className="flex items-center gap-3">
+        <div className="flex items-center gap-2 sm:gap-3 flex-wrap">
           {/* Sort dropdown */}
           {/* Sort dropdown */}
           <div className="flex items-center gap-1">
           <div className="flex items-center gap-1">
             <select
             <select

+ 26 - 4
frontend/src/pages/QueuePage.tsx

@@ -77,7 +77,17 @@ function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat =
   return formatDateTime(dateString, timeFormat);
   return formatDateTime(dateString, timeFormat);
 }
 }
 
 
-function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
+function StatusBadge({ status, waitingReason }: { status: PrintQueueItem['status']; waitingReason?: string | null }) {
+  // Special case: pending with waiting_reason shows as "Waiting"
+  if (status === 'pending' && waitingReason) {
+    return (
+      <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-400 bg-purple-400/10 border-purple-400/20">
+        <Clock className="w-3.5 h-3.5" />
+        Waiting
+      </span>
+    );
+  }
+
   const config = {
   const config = {
     pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: 'Pending' },
     pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: 'Pending' },
     printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' },
     printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' },
@@ -399,9 +409,13 @@ function SortableQueueItem({
           </div>
           </div>
 
 
           <div className="flex items-center gap-3 text-sm text-bambu-gray">
           <div className="flex items-center gap-3 text-sm text-bambu-gray">
-            <span className={`flex items-center gap-1.5 ${item.printer_id === null ? 'text-orange-400' : ''}`}>
+            <span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
               <Printer className="w-3.5 h-3.5" />
               <Printer className="w-3.5 h-3.5" />
-              {item.printer_id === null ? 'Unassigned' : (item.printer_name || `Printer #${item.printer_id}`)}
+              {item.target_model
+                ? `Any ${item.target_model}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
+                : item.printer_id === null
+                  ? 'Unassigned'
+                  : (item.printer_name || `Printer #${item.printer_id}`)}
             </span>
             </span>
             {item.print_time_seconds && (
             {item.print_time_seconds && (
               <span className="flex items-center gap-1.5">
               <span className="flex items-center gap-1.5">
@@ -448,6 +462,14 @@ function SortableQueueItem({
             </div>
             </div>
           )}
           )}
 
 
+          {/* Waiting reason for model-based assignments */}
+          {item.waiting_reason && item.status === 'pending' && (
+            <p className="text-xs text-purple-400 mt-2 flex items-start gap-1">
+              <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
+              <span>{item.waiting_reason}</span>
+            </p>
+          )}
+
           {/* Error message */}
           {/* Error message */}
           {item.error_message && (
           {item.error_message && (
             <p className="text-xs text-red-400 mt-2 flex items-center gap-1">
             <p className="text-xs text-red-400 mt-2 flex items-center gap-1">
@@ -458,7 +480,7 @@ function SortableQueueItem({
         </div>
         </div>
 
 
         {/* Status badge */}
         {/* Status badge */}
-        <StatusBadge status={item.status} />
+        <StatusBadge status={item.status} waitingReason={item.waiting_reason} />
 
 
         {/* Actions */}
         {/* Actions */}
         <div className="flex items-center gap-1">
         <div className="flex items-center gap-1">

+ 73 - 56
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
 import { api } from '../api/client';
@@ -548,14 +548,16 @@ export function SettingsPage() {
   // Local state for camera URL inputs (to avoid saving on every keystroke)
   // Local state for camera URL inputs (to avoid saving on every keystroke)
   const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});
   const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});
   const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
   const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
+  const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());
 
 
   // Initialize local camera URLs from printer data
   // Initialize local camera URLs from printer data
   useEffect(() => {
   useEffect(() => {
     if (printers) {
     if (printers) {
       const urls: Record<number, string> = {};
       const urls: Record<number, string> = {};
       printers.forEach(p => {
       printers.forEach(p => {
-        if (p.external_camera_url && localCameraUrls[p.id] === undefined) {
+        if (p.external_camera_url && !initializedPrinterUrlsRef.current.has(p.id)) {
           urls[p.id] = p.external_camera_url;
           urls[p.id] = p.external_camera_url;
+          initializedPrinterUrlsRef.current.add(p.id);
         }
         }
       });
       });
       if (Object.keys(urls).length > 0) {
       if (Object.keys(urls).length > 0) {
@@ -742,17 +744,20 @@ export function SettingsPage() {
                   <Globe className="w-4 h-4 inline mr-1" />
                   <Globe className="w-4 h-4 inline mr-1" />
                   {t('settings.language')}
                   {t('settings.language')}
                 </label>
                 </label>
-                <select
-                  value={i18n.language}
-                  onChange={(e) => i18n.changeLanguage(e.target.value)}
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                >
-                  {availableLanguages.map((lang) => (
-                    <option key={lang.code} value={lang.code}>
-                      {lang.nativeName} ({lang.name})
-                    </option>
-                  ))}
-                </select>
+                <div className="relative">
+                  <select
+                    value={i18n.language}
+                    onChange={(e) => i18n.changeLanguage(e.target.value)}
+                    className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                  >
+                    {availableLanguages.map((lang) => (
+                      <option key={lang.code} value={lang.code}>
+                        {lang.nativeName} ({lang.name})
+                      </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-1">
                 <p className="text-xs text-bambu-gray mt-1">
                   {t('settings.languageDescription')}
                   {t('settings.languageDescription')}
                 </p>
                 </p>
@@ -761,17 +766,20 @@ export function SettingsPage() {
                 <label className="block text-sm text-bambu-gray mb-1">
                 <label className="block text-sm text-bambu-gray mb-1">
                   {t('settings.defaultView')}
                   {t('settings.defaultView')}
                 </label>
                 </label>
-                <select
-                  value={defaultView}
-                  onChange={(e) => handleDefaultViewChange(e.target.value)}
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                >
-                  {defaultNavItems.map((item) => (
-                    <option key={item.id} value={item.to}>
-                      {t(item.labelKey)}
-                    </option>
-                  ))}
-                </select>
+                <div className="relative">
+                  <select
+                    value={defaultView}
+                    onChange={(e) => handleDefaultViewChange(e.target.value)}
+                    className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                  >
+                    {defaultNavItems.map((item) => (
+                      <option key={item.id} value={item.to}>
+                        {t(item.labelKey)}
+                      </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-1">
                 <p className="text-xs text-bambu-gray mt-1">
                   {t('settings.defaultViewDescription')}
                   {t('settings.defaultViewDescription')}
                 </p>
                 </p>
@@ -781,48 +789,57 @@ export function SettingsPage() {
                   <label className="block text-sm text-bambu-gray mb-1">
                   <label className="block text-sm text-bambu-gray mb-1">
                     Date Format
                     Date Format
                   </label>
                   </label>
-                  <select
-                    value={localSettings.date_format || 'system'}
-                    onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
-                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  >
-                    <option value="system">System Default</option>
-                    <option value="us">US (MM/DD/YYYY)</option>
-                    <option value="eu">EU (DD/MM/YYYY)</option>
-                    <option value="iso">ISO (YYYY-MM-DD)</option>
-                  </select>
+                  <div className="relative">
+                    <select
+                      value={localSettings.date_format || 'system'}
+                      onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
+                      className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                    >
+                      <option value="system">System Default</option>
+                      <option value="us">US (MM/DD/YYYY)</option>
+                      <option value="eu">EU (DD/MM/YYYY)</option>
+                      <option value="iso">ISO (YYYY-MM-DD)</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>
                 </div>
                 </div>
                 <div>
                 <div>
                   <label className="block text-sm text-bambu-gray mb-1">
                   <label className="block text-sm text-bambu-gray mb-1">
                     Time Format
                     Time Format
                   </label>
                   </label>
-                  <select
-                    value={localSettings.time_format || 'system'}
-                    onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
-                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  >
-                    <option value="system">System Default</option>
-                    <option value="12h">12-hour (3:30 PM)</option>
-                    <option value="24h">24-hour (15:30)</option>
-                  </select>
+                  <div className="relative">
+                    <select
+                      value={localSettings.time_format || 'system'}
+                      onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
+                      className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                    >
+                      <option value="system">System Default</option>
+                      <option value="12h">12-hour (3:30 PM)</option>
+                      <option value="24h">24-hour (15:30)</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>
                 </div>
                 </div>
               </div>
               </div>
               <div>
               <div>
                 <label className="block text-sm text-bambu-gray mb-1">
                 <label className="block text-sm text-bambu-gray mb-1">
                   Default Printer
                   Default Printer
                 </label>
                 </label>
-                <select
-                  value={localSettings.default_printer_id ?? ''}
-                  onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                >
-                  <option value="">No default (ask each time)</option>
-                  {printers?.map((printer) => (
-                    <option key={printer.id} value={printer.id}>
-                      {printer.name}
-                    </option>
-                  ))}
-                </select>
+                <div className="relative">
+                  <select
+                    value={localSettings.default_printer_id ?? ''}
+                    onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
+                    className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                  >
+                    <option value="">No default (ask each time)</option>
+                    {printers?.map((printer) => (
+                      <option key={printer.id} value={printer.id}>
+                        {printer.name}
+                      </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-1">
                 <p className="text-xs text-bambu-gray mt-1">
                   Pre-select this printer for uploads, reprints, and other operations.
                   Pre-select this printer for uploads, reprints, and other operations.
                 </p>
                 </p>
@@ -1647,7 +1664,7 @@ export function SettingsPage() {
             </CardHeader>
             </CardHeader>
             <CardContent className="space-y-4">
             <CardContent className="space-y-4">
               <p className="text-sm text-bambu-gray">
               <p className="text-sm text-bambu-gray">
-                Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, and input_boolean entities.
+                Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, input_boolean, and script entities.
               </p>
               </p>
 
 
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">

+ 234 - 0
install/README.md

@@ -0,0 +1,234 @@
+# BamBuddy Installation Scripts
+
+Interactive installation scripts for BamBuddy with support for both native and Docker deployments.
+
+## Quick Start
+
+### Docker Installation (Recommended)
+
+**Linux/macOS:**
+```bash
+curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash
+```
+
+### Native Installation
+
+**Linux/macOS:**
+```bash
+curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash
+```
+
+---
+
+## Scripts Overview
+
+| Script | Platform | Method |
+|--------|----------|--------|
+| `install.sh` | Linux, macOS | Native (Python venv) |
+| `docker-install.sh` | Linux, macOS | Docker |
+
+---
+
+## Native Installation Scripts
+
+### `install.sh` (Linux/macOS)
+
+Installs BamBuddy with Python virtual environment and optional systemd/launchd service.
+
+**Supported Systems:**
+- Debian/Ubuntu (apt)
+- RHEL/Fedora/CentOS (dnf/yum)
+- Arch Linux (pacman)
+- openSUSE (zypper)
+- macOS (Homebrew)
+
+**Options:**
+```
+--path PATH        Installation directory (default: /opt/bambuddy)
+--port PORT        Port to listen on (default: 8000)
+--tz TIMEZONE      Timezone (default: system timezone)
+--data-dir PATH    Data directory (default: INSTALL_PATH/data)
+--log-dir PATH     Log directory (default: INSTALL_PATH/logs)
+--debug            Enable debug mode
+--log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
+--no-service       Skip systemd/launchd service setup
+--yes, -y          Non-interactive mode, accept defaults
+```
+
+**Examples:**
+```bash
+# Interactive installation
+./install.sh
+
+# Unattended with custom settings
+./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes
+
+# Minimal unattended
+./install.sh -y
+
+# Skip service setup
+./install.sh --no-service -y
+```
+
+---
+
+## Docker Installation Scripts
+
+### `docker-install.sh` (Linux/macOS)
+
+Installs BamBuddy using Docker containers.
+
+**Options:**
+```
+--path PATH        Installation directory (default: ~/bambuddy)
+--port PORT        Port to expose (default: 8000)
+--tz TIMEZONE      Timezone (default: system timezone)
+--build            Build from source instead of using pre-built image
+--yes, -y          Non-interactive mode, accept defaults
+```
+
+**Examples:**
+```bash
+# Interactive installation
+./docker-install.sh
+
+# Unattended with custom settings
+./docker-install.sh --path /srv/bambuddy --port 3000 --tz Europe/Berlin --yes
+
+# Build from source
+./docker-install.sh --build --yes
+```
+
+---
+
+## Configuration Options
+
+All scripts support these configuration options:
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| Install Path | Where BamBuddy is installed | `/opt/bambuddy` (Linux/Docker) |
+| Port | HTTP port for web interface | `8000` |
+| Timezone | Server timezone | System timezone or `UTC` |
+| Data Directory | Database and archives | `INSTALL_PATH/data` |
+| Log Directory | Application logs | `INSTALL_PATH/logs` |
+| Debug Mode | Enable verbose logging | `false` |
+| Log Level | INFO, WARNING, ERROR, DEBUG | `INFO` |
+
+---
+
+## Post-Installation
+
+### Accessing BamBuddy
+
+After installation, open your browser to:
+```
+http://localhost:8000
+```
+
+Or use the port you specified during installation.
+
+### Service Management
+
+**Linux (systemd):**
+```bash
+sudo systemctl status bambuddy    # Check status
+sudo systemctl start bambuddy     # Start
+sudo systemctl stop bambuddy      # Stop
+sudo systemctl restart bambuddy   # Restart
+sudo journalctl -u bambuddy -f    # View logs
+```
+
+**macOS (launchd):**
+```bash
+launchctl list | grep bambuddy                              # Check status
+launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist    # Start
+launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist  # Stop
+```
+
+**Docker:**
+```bash
+docker compose ps           # Check status
+docker compose up -d        # Start
+docker compose down         # Stop
+docker compose restart      # Restart
+docker compose logs -f      # View logs
+```
+
+### Updating
+
+**Native installation:**
+```bash
+cd /opt/bambuddy
+git pull
+source venv/bin/activate
+pip install -r requirements.txt
+cd frontend && npm ci && npm run build
+sudo systemctl restart bambuddy  # Linux
+```
+
+**Docker (pre-built image):**
+```bash
+cd ~/bambuddy
+docker compose pull
+docker compose up -d
+```
+
+**Docker (from source):**
+```bash
+cd ~/bambuddy
+git pull
+docker compose up -d --build
+```
+
+---
+
+## Troubleshooting
+
+### Permission Denied (Linux)
+Run with `sudo` or ensure your user has appropriate permissions:
+```bash
+sudo ./install.sh
+```
+
+### Docker: Printer Discovery Not Working
+Docker Desktop for macOS doesn't support host networking. Add printers manually by IP address in the BamBuddy web interface.
+
+### Service Won't Start
+Check logs for errors:
+```bash
+# Linux
+sudo journalctl -u bambuddy -n 50
+
+# Docker
+docker compose logs bambuddy
+```
+
+### Port Already in Use
+Choose a different port during installation or stop the conflicting service:
+```bash
+# Find what's using port 8000
+sudo lsof -i :8000  # Linux/macOS
+```
+
+---
+
+## Requirements
+
+### Native Installation
+- Python 3.10+ (automatically installed if missing)
+- Node.js 18+ (automatically installed if missing)
+- Git (automatically installed if missing)
+- ~500MB disk space
+
+### Docker Installation
+- Docker Engine 20+ or Docker Desktop
+- ~1GB disk space (includes image)
+
+---
+
+## Support
+
+- **Documentation:** https://wiki.bambuddy.cool
+- **Discord:** https://discord.gg/aFS3ZfScHM
+- **Issues:** https://github.com/maziggy/bambuddy/issues

+ 541 - 0
install/docker-install.sh

@@ -0,0 +1,541 @@
+#!/usr/bin/env bash
+#
+# BamBuddy Docker Installation Script
+# Supports: Linux (all distros), macOS
+#
+# Usage:
+#   Interactive:  curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash
+#   Unattended:   ./docker-install.sh --path /opt/bambuddy --port 8000 --yes
+#
+# Options:
+#   --path PATH        Installation directory (default: /opt/bambuddy)
+#   --port PORT        Port to expose (default: 8000)
+#   --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)
+#   --tz TIMEZONE      Timezone (default: system timezone or UTC)
+#   --build            Build from source instead of using pre-built image
+#   --yes, -y          Non-interactive mode, accept defaults
+#   --help, -h         Show this help message
+#
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+BOLD='\033[1m'
+
+# Default values
+DEFAULT_INSTALL_PATH="/opt/bambuddy"
+DEFAULT_PORT="8000"
+DEFAULT_BIND_ADDRESS="0.0.0.0"
+
+# Script variables
+INSTALL_PATH=""
+PORT=""
+BIND_ADDRESS=""
+TIMEZONE=""
+BUILD_FROM_SOURCE="false"
+NON_INTERACTIVE="false"
+OS_TYPE=""
+DOCKER_CMD=""
+
+# -----------------------------------------------------------------------------
+# Helper Functions
+# -----------------------------------------------------------------------------
+
+print_banner() {
+    echo -e "${CYAN}"
+    echo "โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—"
+    echo "โ•‘                                                        โ•‘"
+    echo "โ•‘   ____                  _               _     _        โ•‘"
+    echo "โ•‘  | __ )  __ _ _ __ ___ | |__  _   _  __| | __| |_   _  โ•‘"
+    echo "โ•‘  |  _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | โ•‘"
+    echo "โ•‘  | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | โ•‘"
+    echo "โ•‘  |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | โ•‘"
+    echo "โ•‘                                                 |___/  โ•‘"
+    echo "โ•‘                                                        โ•‘"
+    echo "โ•‘            Docker Installation Script                  โ•‘"
+    echo "โ•‘                                                        โ•‘"
+    echo "โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•"
+    echo -e "${NC}"
+}
+
+log_info() {
+    echo -e "${BLUE}[INFO]${NC} $1"
+}
+
+log_success() {
+    echo -e "${GREEN}[OK]${NC} $1"
+}
+
+log_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+prompt() {
+    local prompt_text="$1"
+    local default_value="$2"
+    local var_name="$3"
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        eval "$var_name=\"$default_value\""
+        return
+    fi
+
+    if [[ -n "$default_value" ]]; then
+        echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
+    else
+        echo -en "${BOLD}$prompt_text${NC}: "
+    fi
+
+    read -r input
+    if [[ -z "$input" ]]; then
+        eval "$var_name=\"$default_value\""
+    else
+        eval "$var_name=\"$input\""
+    fi
+}
+
+prompt_yes_no() {
+    local prompt_text="$1"
+    local default="$2"  # y or n
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        [[ "$default" == "y" ]] && return 0 || return 1
+    fi
+
+    local yn_hint="[y/n]"
+    [[ "$default" == "y" ]] && yn_hint="[Y/n]"
+    [[ "$default" == "n" ]] && yn_hint="[y/N]"
+
+    while true; do
+        echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
+        read -r yn
+        [[ -z "$yn" ]] && yn="$default"
+        case "$yn" in
+            [Yy]* ) return 0;;
+            [Nn]* ) return 1;;
+            * ) echo "Please answer yes or no.";;
+        esac
+    done
+}
+
+show_help() {
+    echo "BamBuddy Docker Installation Script"
+    echo ""
+    echo "Usage: $0 [OPTIONS]"
+    echo ""
+    echo "Options:"
+    echo "  --path PATH        Installation directory (default: /opt/bambuddy)"
+    echo "  --port PORT        Port to expose (default: 8000)"
+    echo "  --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)"
+    echo "  --tz TIMEZONE      Timezone (default: system timezone or UTC)"
+    echo "  --build            Build from source instead of using pre-built image"
+    echo "  --yes, -y          Non-interactive mode, accept defaults"
+    echo "  --help, -h         Show this help message"
+    echo ""
+    echo "Examples:"
+    echo "  Interactive installation:"
+    echo "    ./docker-install.sh"
+    echo ""
+    echo "  Unattended installation with custom settings:"
+    echo "    ./docker-install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes"
+    echo ""
+    echo "  Build from source:"
+    echo "    ./docker-install.sh --build --yes"
+    exit 0
+}
+
+# -----------------------------------------------------------------------------
+# System Detection
+# -----------------------------------------------------------------------------
+
+detect_os() {
+    if [[ "$OSTYPE" == "darwin"* ]]; then
+        OS_TYPE="macos"
+        return
+    fi
+
+    if [[ -f /etc/os-release ]]; then
+        OS_TYPE="linux"
+    else
+        log_error "Cannot detect operating system"
+        exit 1
+    fi
+}
+
+detect_docker() {
+    # Check for docker compose (v2) or docker-compose (v1)
+    if docker compose version &>/dev/null 2>&1; then
+        DOCKER_CMD="docker compose"
+        log_success "Found Docker Compose v2"
+        return 0
+    elif docker-compose --version &>/dev/null 2>&1; then
+        DOCKER_CMD="docker-compose"
+        log_success "Found Docker Compose v1"
+        return 0
+    fi
+    return 1
+}
+
+detect_timezone() {
+    if [[ -n "$TIMEZONE" ]]; then
+        return 0
+    fi
+
+    # Try to get system timezone (with error handling for set -e)
+    TIMEZONE=""
+    if [[ -f /etc/timezone ]]; then
+        TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
+        TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
+        TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
+    fi
+
+    # Default to UTC if not found (use if/then to avoid set -e issue with &&)
+    if [[ -z "$TIMEZONE" ]]; then
+        TIMEZONE="UTC"
+    fi
+    return 0
+}
+
+# -----------------------------------------------------------------------------
+# Installation Functions
+# -----------------------------------------------------------------------------
+
+install_docker() {
+    log_info "Docker not found, installing..."
+
+    case "$OS_TYPE" in
+        linux)
+            # Use Docker's convenience script
+            curl -fsSL https://get.docker.com | sh
+
+            # Add current user to docker group
+            if [[ -n "$SUDO_USER" ]]; then
+                sudo usermod -aG docker "$SUDO_USER"
+                log_warn "Added $SUDO_USER to docker group. You may need to log out and back in."
+            else
+                sudo usermod -aG docker "$USER"
+                log_warn "Added $USER to docker group. You may need to log out and back in."
+            fi
+
+            # Start Docker service
+            sudo systemctl enable docker
+            sudo systemctl start docker
+            ;;
+        macos)
+            log_error "Docker Desktop not found."
+            log_error "Please install Docker Desktop for Mac from: https://www.docker.com/products/docker-desktop"
+            exit 1
+            ;;
+    esac
+
+    log_success "Docker installed"
+}
+
+create_install_dir() {
+    log_info "Creating installation directory..."
+
+    mkdir -p "$INSTALL_PATH"
+    cd "$INSTALL_PATH"
+
+    log_success "Directory created: $INSTALL_PATH"
+}
+
+download_compose_file() {
+    log_info "Downloading docker-compose.yml..."
+
+    if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
+        # Clone the full repo for building
+        if [[ -d ".git" ]]; then
+            log_info "Existing repository found, updating..."
+            git fetch origin
+            git reset --hard origin/main
+        else
+            git clone https://github.com/maziggy/bambuddy.git .
+        fi
+    else
+        # Just download the compose file
+        curl -fsSL -o docker-compose.yml \
+            https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml
+    fi
+
+    log_success "docker-compose.yml ready"
+}
+
+create_env_file() {
+    log_info "Creating environment configuration..."
+
+    cat > .env << EOF
+# BamBuddy Docker Configuration
+# Generated by docker-install.sh on $(date)
+
+# Port BamBuddy runs on
+PORT=$PORT
+
+# Timezone
+TZ=$TIMEZONE
+EOF
+
+    log_success "Environment file created"
+}
+
+customize_compose() {
+    # Detect if we need to disable host networking (macOS/Windows in Docker Desktop)
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        log_warn "Docker Desktop detected. Host networking is not supported."
+        log_info "Modifying docker-compose.yml for port mapping..."
+
+        # Create a modified compose file for macOS
+        if [[ -f docker-compose.yml ]]; then
+            # Comment out network_mode: host and uncomment ports section
+            sed -i.bak \
+                -e 's/^[[:space:]]*network_mode: host/#    network_mode: host/' \
+                -e 's/^[[:space:]]*#ports:/    ports:/' \
+                -e 's/^[[:space:]]*#[[:space:]]*- "\${PORT:-8000}:8000"/      - "\${PORT:-8000}:8000"/' \
+                docker-compose.yml
+
+            log_warn "Printer discovery may not work. Add printers manually by IP address."
+        fi
+    fi
+}
+
+start_container() {
+    log_info "Starting BamBuddy..."
+
+    if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
+        $DOCKER_CMD up -d --build
+    else
+        $DOCKER_CMD up -d
+    fi
+
+    # Wait for container to start
+    log_info "Waiting for container to start..."
+    local max_attempts=15
+    local attempt=0
+
+    while [[ $attempt -lt $max_attempts ]]; do
+        # Check if container is running (Up)
+        if $DOCKER_CMD ps | grep -q "Up"; then
+            log_success "BamBuddy container is running"
+            return 0
+        fi
+
+        # Check if container failed
+        if $DOCKER_CMD ps -a | grep -q "Exited"; then
+            log_error "Container failed to start"
+            log_info "Check logs with: $DOCKER_CMD logs bambuddy"
+            return 1
+        fi
+
+        sleep 2
+        ((attempt++))
+    done
+
+    log_warn "Container may still be starting. Check with: $DOCKER_CMD ps"
+}
+
+# -----------------------------------------------------------------------------
+# Main Installation Flow
+# -----------------------------------------------------------------------------
+
+parse_args() {
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --path)
+                INSTALL_PATH="$2"
+                shift 2
+                ;;
+            --port)
+                PORT="$2"
+                shift 2
+                ;;
+            --bind)
+                BIND_ADDRESS="$2"
+                shift 2
+                ;;
+            --tz)
+                TIMEZONE="$2"
+                shift 2
+                ;;
+            --build)
+                BUILD_FROM_SOURCE="true"
+                shift
+                ;;
+            --yes|-y)
+                NON_INTERACTIVE="true"
+                shift
+                ;;
+            --help|-h)
+                show_help
+                ;;
+            *)
+                log_error "Unknown option: $1"
+                show_help
+                ;;
+        esac
+    done
+}
+
+gather_config() {
+    echo ""
+    echo -e "${BOLD}Installation Configuration${NC}"
+    echo -e "${CYAN}โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€${NC}"
+    echo ""
+
+    # Installation path
+    [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
+
+    # Port
+    [[ -z "$PORT" ]] && prompt "Port to expose" "$DEFAULT_PORT" PORT
+
+    # Bind address
+    if [[ -z "$BIND_ADDRESS" ]]; then
+        echo ""
+        echo "Network access:"
+        echo "  0.0.0.0   - Accessible from other devices on your network (recommended)"
+        echo "  127.0.0.1 - Only accessible from this machine"
+        prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
+    fi
+
+    # Timezone
+    detect_timezone
+    prompt "Timezone" "$TIMEZONE" TIMEZONE
+
+    # Build from source?
+    if [[ "$BUILD_FROM_SOURCE" != "true" ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
+        if prompt_yes_no "Build from source? (No = use pre-built image)" "n"; then
+            BUILD_FROM_SOURCE="true"
+        fi
+    fi
+
+    # Confirm
+    echo ""
+    echo -e "${BOLD}Installation Summary${NC}"
+    echo -e "${CYAN}โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€${NC}"
+    echo -e "  Install path:  ${GREEN}$INSTALL_PATH${NC}"
+    echo -e "  Port:          ${GREEN}$PORT${NC}"
+    echo -e "  Bind address:  ${GREEN}$BIND_ADDRESS${NC}"
+    echo -e "  Timezone:      ${GREEN}$TIMEZONE${NC}"
+    echo -e "  Build source:  ${GREEN}$BUILD_FROM_SOURCE${NC}"
+    echo ""
+
+    if ! prompt_yes_no "Proceed with installation?" "y"; then
+        echo "Installation cancelled."
+        exit 0
+    fi
+}
+
+main() {
+    parse_args "$@"
+    print_banner
+
+    # Check if running via pipe (curl | bash) - interactive mode won't work
+    if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
+        log_error "Interactive mode requires a terminal."
+        log_info "When using 'curl | bash', you must use non-interactive mode:"
+        echo ""
+        echo "    curl -fsSL URL | bash -s -- --yes"
+        echo ""
+        log_info "Or download and run directly:"
+        echo ""
+        echo "    curl -fsSL URL -o docker-install.sh && chmod +x docker-install.sh && ./docker-install.sh"
+        echo ""
+        exit 1
+    fi
+
+    # Detect system
+    log_info "Detecting system..."
+    detect_os
+    log_success "Detected: $OS_TYPE"
+
+    # Check for Docker
+    if ! command -v docker &>/dev/null; then
+        install_docker
+    fi
+
+    if ! detect_docker; then
+        log_error "Docker Compose not found. Please install Docker Compose."
+        exit 1
+    fi
+
+    # Check if Docker daemon is running
+    if ! docker info &>/dev/null; then
+        log_error "Docker daemon is not running. Please start Docker and try again."
+        exit 1
+    fi
+
+    # Gather configuration
+    gather_config
+
+    # Install steps
+    echo ""
+    echo -e "${BOLD}Starting Installation${NC}"
+    echo -e "${CYAN}โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€${NC}"
+    echo ""
+
+    create_install_dir
+    download_compose_file
+    create_env_file
+    customize_compose
+    start_container
+
+    # Done!
+    echo ""
+    echo -e "${GREEN}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}"
+    echo -e "${GREEN}โ•‘                                                              โ•‘${NC}"
+    echo -e "${GREEN}โ•‘              Installation Complete!                          โ•‘${NC}"
+    echo -e "${GREEN}โ•‘                                                              โ•‘${NC}"
+    echo -e "${GREEN}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+    echo ""
+    # Show appropriate URL based on bind address
+    if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
+        local ip_addr
+        ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+        echo -e "                    ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
+    else
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Manage container:${NC}"
+    echo -e "    Status:  cd $INSTALL_PATH && $DOCKER_CMD ps"
+    echo -e "    Logs:    cd $INSTALL_PATH && $DOCKER_CMD logs -f bambuddy"
+    echo -e "    Stop:    cd $INSTALL_PATH && $DOCKER_CMD down"
+    echo -e "    Start:   cd $INSTALL_PATH && $DOCKER_CMD up -d"
+    echo -e "    Restart: cd $INSTALL_PATH && $DOCKER_CMD restart"
+    echo ""
+    echo -e "  ${BOLD}Update BamBuddy:${NC}"
+    if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
+        echo -e "    cd $INSTALL_PATH && git pull && $DOCKER_CMD up -d --build"
+    else
+        echo -e "    cd $INSTALL_PATH && $DOCKER_CMD pull && $DOCKER_CMD up -d"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Data location:${NC}  Docker volumes (bambuddy_data, bambuddy_logs)"
+    echo ""
+    echo -e "  ${BOLD}Documentation:${NC}  ${CYAN}https://wiki.bambuddy.cool${NC}"
+    echo ""
+
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        echo -e "  ${YELLOW}Note:${NC} Printer discovery may not work with Docker Desktop."
+        echo -e "        Add printers manually using their IP address."
+        echo ""
+    fi
+}
+
+main "$@"

+ 883 - 0
install/install.sh

@@ -0,0 +1,883 @@
+#!/usr/bin/env bash
+#
+# BamBuddy Native Installation Script
+# Supports: Debian/Ubuntu, RHEL/Fedora/CentOS, Arch Linux, macOS
+#
+# Usage:
+#   Interactive:  curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash
+#   Unattended:   ./install.sh --path /opt/bambuddy --port 8000 --yes
+#
+# Options:
+#   --path PATH        Installation directory (default: /opt/bambuddy)
+#   --port PORT        Port to listen on (default: 8000)
+#   --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)
+#   --tz TIMEZONE      Timezone (default: system timezone or UTC)
+#   --data-dir PATH    Data directory (default: INSTALL_PATH/data)
+#   --log-dir PATH     Log directory (default: INSTALL_PATH/logs)
+#   --debug            Enable debug mode
+#   --log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
+#   --no-service       Skip systemd service setup (Linux only)
+#   --set-system-tz    Set system timezone to match (for unattended installs)
+#   --yes, -y          Non-interactive mode, accept defaults
+#   --help, -h         Show this help message
+#
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+BOLD='\033[1m'
+
+# Default values
+DEFAULT_INSTALL_PATH="/opt/bambuddy"
+DEFAULT_PORT="8000"
+DEFAULT_BIND_ADDRESS="0.0.0.0"
+DEFAULT_LOG_LEVEL="INFO"
+DEFAULT_DEBUG="false"
+
+# Script variables
+INSTALL_PATH=""
+PORT=""
+BIND_ADDRESS=""
+TIMEZONE=""
+DATA_DIR=""
+LOG_DIR=""
+DEBUG_MODE=""
+LOG_LEVEL=""
+SKIP_SERVICE="false"
+SET_SYSTEM_TZ=""
+NON_INTERACTIVE="false"
+OS_TYPE=""
+PKG_MANAGER=""
+PYTHON_CMD=""
+SERVICE_USER="bambuddy"
+
+# -----------------------------------------------------------------------------
+# Helper Functions
+# -----------------------------------------------------------------------------
+
+print_banner() {
+    echo -e "${CYAN}"
+    echo "โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—"
+    echo "โ•‘                                                        โ•‘"
+    echo "โ•‘   ____                  _               _     _        โ•‘"
+    echo "โ•‘  | __ )  __ _ _ __ ___ | |__  _   _  __| | __| |_   _  โ•‘"
+    echo "โ•‘  |  _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | โ•‘"
+    echo "โ•‘  | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | โ•‘"
+    echo "โ•‘  |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | โ•‘"
+    echo "โ•‘                                                 |___/  โ•‘"
+    echo "โ•‘                                                        โ•‘"
+    echo "โ•‘            Native Installation Script                  โ•‘"
+    echo "โ•‘                                                        โ•‘"
+    echo "โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•"
+    echo -e "${NC}"
+}
+
+log_info() {
+    echo -e "${BLUE}[INFO]${NC} $1"
+}
+
+log_success() {
+    echo -e "${GREEN}[OK]${NC} $1"
+}
+
+log_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+prompt() {
+    local prompt_text="$1"
+    local default_value="$2"
+    local var_name="$3"
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        eval "$var_name=\"$default_value\""
+        return
+    fi
+
+    if [[ -n "$default_value" ]]; then
+        echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
+    else
+        echo -en "${BOLD}$prompt_text${NC}: "
+    fi
+
+    read -r input
+    if [[ -z "$input" ]]; then
+        eval "$var_name=\"$default_value\""
+    else
+        eval "$var_name=\"$input\""
+    fi
+}
+
+prompt_yes_no() {
+    local prompt_text="$1"
+    local default="$2"  # y or n
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        [[ "$default" == "y" ]] && return 0 || return 1
+    fi
+
+    local yn_hint="[y/n]"
+    [[ "$default" == "y" ]] && yn_hint="[Y/n]"
+    [[ "$default" == "n" ]] && yn_hint="[y/N]"
+
+    while true; do
+        echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
+        read -r yn
+        [[ -z "$yn" ]] && yn="$default"
+        case "$yn" in
+            [Yy]* ) return 0;;
+            [Nn]* ) return 1;;
+            * ) echo "Please answer yes or no.";;
+        esac
+    done
+}
+
+show_help() {
+    echo "BamBuddy Native Installation Script"
+    echo ""
+    echo "Usage: $0 [OPTIONS]"
+    echo ""
+    echo "Options:"
+    echo "  --path PATH        Installation directory (default: /opt/bambuddy)"
+    echo "  --port PORT        Port to listen on (default: 8000)"
+    echo "  --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)"
+    echo "  --tz TIMEZONE      Timezone (default: system timezone or UTC)"
+    echo "  --data-dir PATH    Data directory (default: INSTALL_PATH/data)"
+    echo "  --log-dir PATH     Log directory (default: INSTALL_PATH/logs)"
+    echo "  --debug            Enable debug mode"
+    echo "  --log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)"
+    echo "  --no-service       Skip systemd service setup (Linux only)"
+    echo "  --set-system-tz    Set system timezone to match (for unattended installs)"
+    echo "  --yes, -y          Non-interactive mode, accept defaults"
+    echo "  --help, -h         Show this help message"
+    echo ""
+    echo "Examples:"
+    echo "  Interactive installation:"
+    echo "    ./install.sh"
+    echo ""
+    echo "  Unattended installation with custom settings:"
+    echo "    ./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes"
+    echo ""
+    echo "  Minimal unattended installation:"
+    echo "    ./install.sh -y"
+    exit 0
+}
+
+# -----------------------------------------------------------------------------
+# System Detection
+# -----------------------------------------------------------------------------
+
+detect_os() {
+    if [[ "$OSTYPE" == "darwin"* ]]; then
+        OS_TYPE="macos"
+        PKG_MANAGER="brew"
+        return
+    fi
+
+    if [[ -f /etc/os-release ]]; then
+        . /etc/os-release
+        case "$ID" in
+            ubuntu|debian|raspbian|linuxmint|pop)
+                OS_TYPE="debian"
+                PKG_MANAGER="apt"
+                ;;
+            fedora|rhel|centos|rocky|almalinux|ol)
+                OS_TYPE="rhel"
+                if command -v dnf &>/dev/null; then
+                    PKG_MANAGER="dnf"
+                else
+                    PKG_MANAGER="yum"
+                fi
+                ;;
+            arch|manjaro|endeavouros)
+                OS_TYPE="arch"
+                PKG_MANAGER="pacman"
+                ;;
+            opensuse*|sles)
+                OS_TYPE="suse"
+                PKG_MANAGER="zypper"
+                ;;
+            *)
+                log_error "Unsupported Linux distribution: $ID"
+                exit 1
+                ;;
+        esac
+    else
+        log_error "Cannot detect operating system"
+        exit 1
+    fi
+}
+
+detect_python() {
+    # Try python3 first, then python
+    if command -v python3 &>/dev/null; then
+        PYTHON_CMD="python3"
+    elif command -v python &>/dev/null; then
+        local version
+        version=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1)
+        if [[ "$version" -ge 3 ]]; then
+            PYTHON_CMD="python"
+        fi
+    fi
+
+    if [[ -z "$PYTHON_CMD" ]]; then
+        return 1
+    fi
+
+    # Check version >= 3.10
+    local version
+    version=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
+    local major minor
+    major=$(echo "$version" | cut -d'.' -f1)
+    minor=$(echo "$version" | cut -d'.' -f2)
+
+    if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then
+        log_warn "Python $version found, but 3.10+ is required"
+        return 1
+    fi
+
+    log_success "Found Python $version"
+    return 0
+}
+
+detect_timezone() {
+    if [[ -n "$TIMEZONE" ]]; then
+        return 0
+    fi
+
+    # Try to get system timezone (with error handling for set -e)
+    TIMEZONE=""
+    if [[ -f /etc/timezone ]]; then
+        TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
+        TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
+        TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
+    fi
+
+    # Default to UTC if not found (use if/then to avoid set -e issue with &&)
+    if [[ -z "$TIMEZONE" ]]; then
+        TIMEZONE="UTC"
+    fi
+    return 0
+}
+
+# -----------------------------------------------------------------------------
+# Package Installation
+# -----------------------------------------------------------------------------
+
+install_dependencies() {
+    log_info "Installing system dependencies..."
+
+    case "$PKG_MANAGER" in
+        apt)
+            sudo apt-get update
+            sudo apt-get install -y python3 python3-pip python3-venv git curl ffmpeg
+            ;;
+        dnf|yum)
+            sudo $PKG_MANAGER install -y python3 python3-pip git curl ffmpeg
+            ;;
+        pacman)
+            sudo pacman -Sy --noconfirm python python-pip git curl ffmpeg
+            ;;
+        zypper)
+            sudo zypper install -y python3 python3-pip git curl ffmpeg
+            ;;
+        brew)
+            # Check if Homebrew is installed
+            if ! command -v brew &>/dev/null; then
+                log_error "Homebrew not found. Please install it first: https://brew.sh"
+                exit 1
+            fi
+            brew install python git curl ffmpeg
+            ;;
+    esac
+
+    log_success "System dependencies installed"
+}
+
+# -----------------------------------------------------------------------------
+# Installation Steps
+# -----------------------------------------------------------------------------
+
+create_user() {
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        return  # Skip user creation on macOS
+    fi
+
+    if id "$SERVICE_USER" &>/dev/null; then
+        log_info "User '$SERVICE_USER' already exists"
+        return
+    fi
+
+    log_info "Creating service user '$SERVICE_USER'..."
+    sudo useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$SERVICE_USER"
+    log_success "Service user created"
+}
+
+download_bambuddy() {
+    log_info "Downloading BamBuddy..."
+
+    if [[ -d "$INSTALL_PATH/.git" ]]; then
+        log_info "Existing installation found, updating..."
+        # Add safe.directory to avoid "dubious ownership" error when running as root
+        git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
+        cd "$INSTALL_PATH"
+        git fetch origin
+        git reset --hard origin/main
+        # Ensure correct ownership after update
+        sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
+    else
+        sudo mkdir -p "$INSTALL_PATH"
+        sudo chown "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
+        git clone https://github.com/maziggy/bambuddy.git "$INSTALL_PATH"
+        sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
+    fi
+
+    log_success "BamBuddy downloaded to $INSTALL_PATH"
+}
+
+setup_virtualenv() {
+    log_info "Setting up Python virtual environment..."
+
+    cd "$INSTALL_PATH"
+
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        $PYTHON_CMD -m venv venv
+        source venv/bin/activate
+    else
+        sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv venv 2>/dev/null || $PYTHON_CMD -m venv venv
+        source venv/bin/activate
+    fi
+
+    pip install --upgrade pip
+    pip install -r requirements.txt
+
+    log_success "Virtual environment configured"
+}
+
+check_node_version() {
+    # Returns 0 if Node.js 20+ is available, 1 otherwise
+    if ! command -v node &>/dev/null; then
+        return 1
+    fi
+
+    local version
+    version=$(node --version 2>/dev/null | sed 's/^v//')
+    local major
+    major=$(echo "$version" | cut -d'.' -f1)
+
+    if [[ "$major" -ge 20 ]]; then
+        log_success "Found Node.js v$version"
+        return 0
+    else
+        log_warn "Found Node.js v$version (need 20+)"
+        return 1
+    fi
+}
+
+install_nodejs() {
+    log_info "Installing Node.js 22..."
+    case "$PKG_MANAGER" in
+        apt)
+            # Remove old nodejs if present
+            sudo apt-get remove -y nodejs npm 2>/dev/null || true
+            curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
+            sudo apt-get install -y nodejs
+            ;;
+        dnf|yum)
+            sudo $PKG_MANAGER remove -y nodejs npm 2>/dev/null || true
+            curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
+            sudo $PKG_MANAGER install -y nodejs
+            ;;
+        pacman)
+            sudo pacman -S --noconfirm nodejs npm
+            ;;
+        zypper)
+            sudo zypper install -y nodejs22
+            ;;
+        brew)
+            brew install node@22
+            brew link --overwrite node@22
+            ;;
+        *)
+            log_error "Please install Node.js 20+ manually: https://nodejs.org/"
+            exit 1
+            ;;
+    esac
+    # Refresh PATH
+    hash -r 2>/dev/null || true
+}
+
+build_frontend() {
+    log_info "Building frontend..."
+
+    cd "$INSTALL_PATH/frontend"
+
+    # Check for Node.js 20+
+    if ! check_node_version; then
+        install_nodejs
+        # Verify installation
+        if ! check_node_version; then
+            log_error "Failed to install Node.js 20+. Please install manually."
+            exit 1
+        fi
+    fi
+
+    npm ci
+    npm run build
+
+    log_success "Frontend built"
+}
+
+create_directories() {
+    log_info "Creating data directories..."
+
+    sudo mkdir -p "$DATA_DIR" "$LOG_DIR"
+
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
+    fi
+
+    log_success "Directories created"
+}
+
+create_env_file() {
+    log_info "Creating environment configuration..."
+
+    local env_file="$INSTALL_PATH/.env"
+
+    # Note: Only include settings recognized by the app's pydantic Settings class
+    # Other settings (PORT, BIND_ADDRESS, DATA_DIR, LOG_DIR, TZ) are set in systemd service
+    cat > /tmp/bambuddy.env << EOF
+# BamBuddy Configuration
+# Generated by install.sh on $(date)
+
+# Debug mode (true = verbose logging)
+DEBUG=$DEBUG_MODE
+
+# Log level (only used when DEBUG=false)
+# Options: DEBUG, INFO, WARNING, ERROR
+LOG_LEVEL=$LOG_LEVEL
+
+# Enable file logging
+LOG_TO_FILE=true
+EOF
+
+    sudo mv /tmp/bambuddy.env "$env_file"
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        sudo chown "$SERVICE_USER:$SERVICE_USER" "$env_file"
+    fi
+    sudo chmod 600 "$env_file"
+
+    log_success "Environment file created at $env_file"
+}
+
+create_systemd_service() {
+    if [[ "$OS_TYPE" == "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
+        return
+    fi
+
+    log_info "Creating systemd service..."
+
+    cat > /tmp/bambuddy.service << EOF
+[Unit]
+Description=BamBuddy - Bambu Lab Print Management
+Documentation=https://github.com/maziggy/bambuddy
+After=network.target
+
+[Service]
+Type=simple
+User=$SERVICE_USER
+Group=$SERVICE_USER
+WorkingDirectory=$INSTALL_PATH
+
+# App settings from .env file
+EnvironmentFile=$INSTALL_PATH/.env
+
+# Service settings (not in .env to avoid pydantic validation errors)
+Environment="DATA_DIR=$DATA_DIR"
+Environment="LOG_DIR=$LOG_DIR"
+Environment="TZ=$TIMEZONE"
+
+ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT
+Restart=on-failure
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+
+# Security hardening
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+    sudo mv /tmp/bambuddy.service /etc/systemd/system/bambuddy.service
+    sudo systemctl daemon-reload
+
+    log_success "Systemd service created"
+
+    if prompt_yes_no "Enable BamBuddy to start on boot?" "y"; then
+        sudo systemctl enable bambuddy
+        log_success "Service enabled"
+    fi
+
+    if prompt_yes_no "Start BamBuddy now?" "y"; then
+        sudo systemctl start bambuddy
+        sleep 2
+        if sudo systemctl is-active --quiet bambuddy; then
+            log_success "BamBuddy is running"
+        else
+            log_warn "Service may have failed to start. Check: sudo journalctl -u bambuddy -f"
+        fi
+    fi
+}
+
+create_launchd_service() {
+    if [[ "$OS_TYPE" != "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
+        return
+    fi
+
+    log_info "Creating launchd service..."
+
+    local plist_path="$HOME/Library/LaunchAgents/com.bambuddy.app.plist"
+
+    cat > "$plist_path" << EOF
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>Label</key>
+    <string>com.bambuddy.app</string>
+    <key>ProgramArguments</key>
+    <array>
+        <string>$INSTALL_PATH/venv/bin/uvicorn</string>
+        <string>backend.app.main:app</string>
+        <string>--host</string>
+        <string>$BIND_ADDRESS</string>
+        <string>--port</string>
+        <string>$PORT</string>
+    </array>
+    <key>WorkingDirectory</key>
+    <string>$INSTALL_PATH</string>
+    <key>EnvironmentVariables</key>
+    <dict>
+        <key>DEBUG</key>
+        <string>$DEBUG_MODE</string>
+        <key>LOG_LEVEL</key>
+        <string>$LOG_LEVEL</string>
+        <key>DATA_DIR</key>
+        <string>$DATA_DIR</string>
+        <key>LOG_DIR</key>
+        <string>$LOG_DIR</string>
+        <key>TZ</key>
+        <string>$TIMEZONE</string>
+    </dict>
+    <key>RunAtLoad</key>
+    <true/>
+    <key>KeepAlive</key>
+    <true/>
+    <key>StandardOutPath</key>
+    <string>$LOG_DIR/bambuddy.log</string>
+    <key>StandardErrorPath</key>
+    <string>$LOG_DIR/bambuddy.error.log</string>
+</dict>
+</plist>
+EOF
+
+    log_success "Launchd plist created at $plist_path"
+
+    if prompt_yes_no "Load BamBuddy service now?" "y"; then
+        launchctl load "$plist_path"
+        sleep 2
+        if launchctl list | grep -q "com.bambuddy.app"; then
+            log_success "BamBuddy is running"
+        else
+            log_warn "Service may have failed to start. Check: cat $LOG_DIR/bambuddy.error.log"
+        fi
+    fi
+}
+
+# -----------------------------------------------------------------------------
+# Main Installation Flow
+# -----------------------------------------------------------------------------
+
+parse_args() {
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --path)
+                INSTALL_PATH="$2"
+                shift 2
+                ;;
+            --port)
+                PORT="$2"
+                shift 2
+                ;;
+            --bind)
+                BIND_ADDRESS="$2"
+                shift 2
+                ;;
+            --tz)
+                TIMEZONE="$2"
+                shift 2
+                ;;
+            --data-dir)
+                DATA_DIR="$2"
+                shift 2
+                ;;
+            --log-dir)
+                LOG_DIR="$2"
+                shift 2
+                ;;
+            --debug)
+                DEBUG_MODE="true"
+                shift
+                ;;
+            --log-level)
+                LOG_LEVEL="$2"
+                shift 2
+                ;;
+            --no-service)
+                SKIP_SERVICE="true"
+                shift
+                ;;
+            --set-system-tz)
+                SET_SYSTEM_TZ="true"
+                shift
+                ;;
+            --yes|-y)
+                NON_INTERACTIVE="true"
+                shift
+                ;;
+            --help|-h)
+                show_help
+                ;;
+            *)
+                log_error "Unknown option: $1"
+                show_help
+                ;;
+        esac
+    done
+}
+
+gather_config() {
+    echo ""
+    echo -e "${BOLD}Installation Configuration${NC}"
+    echo -e "${CYAN}โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€${NC}"
+    echo ""
+
+    # Installation path
+    [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
+
+    # Port
+    [[ -z "$PORT" ]] && prompt "Port to listen on" "$DEFAULT_PORT" PORT
+
+    # Bind address
+    if [[ -z "$BIND_ADDRESS" ]]; then
+        echo ""
+        echo "Network access:"
+        echo "  0.0.0.0   - Accessible from other devices on your network (recommended)"
+        echo "  127.0.0.1 - Only accessible from this machine"
+        prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
+    fi
+
+    # Timezone
+    detect_timezone
+    prompt "Timezone" "$TIMEZONE" TIMEZONE
+
+    # Offer to set system timezone if different from current (skip if already set via --set-system-tz)
+    if [[ -z "$SET_SYSTEM_TZ" ]]; then
+        local current_tz
+        current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
+        if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "$current_tz" ]]; then
+            # Default to "n" so unattended installs don't change system TZ unless --set-system-tz is used
+            if prompt_yes_no "Set system timezone to $TIMEZONE?" "n"; then
+                SET_SYSTEM_TZ="true"
+            else
+                SET_SYSTEM_TZ="false"
+            fi
+        else
+            SET_SYSTEM_TZ="false"
+        fi
+    fi
+
+    # Data directory
+    [[ -z "$DATA_DIR" ]] && DATA_DIR="$INSTALL_PATH/data"
+    prompt "Data directory" "$DATA_DIR" DATA_DIR
+
+    # Log directory
+    [[ -z "$LOG_DIR" ]] && LOG_DIR="$INSTALL_PATH/logs"
+    prompt "Log directory" "$LOG_DIR" LOG_DIR
+
+    # Debug mode
+    if [[ -z "$DEBUG_MODE" ]]; then
+        if prompt_yes_no "Enable debug mode?" "n"; then
+            DEBUG_MODE="true"
+        else
+            DEBUG_MODE="false"
+        fi
+    fi
+
+    # Log level
+    if [[ -z "$LOG_LEVEL" ]]; then
+        echo ""
+        echo "Log levels: DEBUG, INFO, WARNING, ERROR"
+        prompt "Log level" "$DEFAULT_LOG_LEVEL" LOG_LEVEL
+    fi
+
+    # Confirm
+    echo ""
+    echo -e "${BOLD}Installation Summary${NC}"
+    echo -e "${CYAN}โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€${NC}"
+    echo -e "  Install path:  ${GREEN}$INSTALL_PATH${NC}"
+    echo -e "  Port:          ${GREEN}$PORT${NC}"
+    echo -e "  Bind address:  ${GREEN}$BIND_ADDRESS${NC}"
+    echo -e "  Timezone:      ${GREEN}$TIMEZONE${NC}"
+    echo -e "  Data dir:      ${GREEN}$DATA_DIR${NC}"
+    echo -e "  Log dir:       ${GREEN}$LOG_DIR${NC}"
+    echo -e "  Debug mode:    ${GREEN}$DEBUG_MODE${NC}"
+    echo -e "  Log level:     ${GREEN}$LOG_LEVEL${NC}"
+    echo ""
+
+    if ! prompt_yes_no "Proceed with installation?" "y"; then
+        echo "Installation cancelled."
+        exit 0
+    fi
+}
+
+main() {
+    parse_args "$@"
+    print_banner
+
+    # Check if running via pipe (curl | bash) - interactive mode won't work
+    if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
+        log_error "Interactive mode requires a terminal."
+        log_info "When using 'curl | bash', you must use non-interactive mode:"
+        echo ""
+        echo "    curl -fsSL URL | bash -s -- --yes"
+        echo ""
+        log_info "Or download and run directly:"
+        echo ""
+        echo "    curl -fsSL URL -o install.sh && chmod +x install.sh && ./install.sh"
+        echo ""
+        exit 1
+    fi
+
+    # Check for root (we need sudo for some operations)
+    if [[ "$EUID" -eq 0 ]] && [[ "$OS_TYPE" != "macos" ]]; then
+        log_warn "Running as root. Consider using a regular user with sudo privileges."
+    fi
+
+    # Detect system
+    log_info "Detecting system..."
+    detect_os
+    log_success "Detected: $OS_TYPE (package manager: $PKG_MANAGER)"
+
+    # Check/install Python
+    if ! detect_python; then
+        log_info "Python 3.10+ not found, will install..."
+    fi
+
+    # Gather configuration
+    gather_config
+
+    # Install steps
+    echo ""
+    echo -e "${BOLD}Starting Installation${NC}"
+    echo -e "${CYAN}โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€${NC}"
+    echo ""
+
+    install_dependencies
+    detect_python || { log_error "Failed to install Python"; exit 1; }
+
+    # Set system timezone if requested
+    if [[ "$SET_SYSTEM_TZ" == "true" ]]; then
+        log_info "Setting system timezone to $TIMEZONE..."
+        if [[ "$OS_TYPE" == "macos" ]]; then
+            sudo systemsetup -settimezone "$TIMEZONE" 2>/dev/null || true
+        else
+            sudo timedatectl set-timezone "$TIMEZONE" 2>/dev/null || true
+        fi
+        log_success "System timezone set to $TIMEZONE"
+    fi
+
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        create_user
+    else
+        SERVICE_USER="$USER"
+    fi
+
+    download_bambuddy
+    setup_virtualenv
+    build_frontend
+    create_directories
+    create_env_file
+
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        create_launchd_service
+    else
+        create_systemd_service
+    fi
+
+    # Done!
+    echo ""
+    echo -e "${GREEN}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}"
+    echo -e "${GREEN}โ•‘                                                              โ•‘${NC}"
+    echo -e "${GREEN}โ•‘              Installation Complete!                          โ•‘${NC}"
+    echo -e "${GREEN}โ•‘                                                              โ•‘${NC}"
+    echo -e "${GREEN}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+    echo ""
+    # Show appropriate URL based on bind address
+    if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
+        local ip_addr
+        ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+        echo -e "                    ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
+    else
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+    fi
+    echo ""
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        echo -e "  ${BOLD}Manage service:${NC}"
+        echo -e "    Start:   launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist"
+        echo -e "    Stop:    launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist"
+        echo -e "    Logs:    tail -f $LOG_DIR/bambuddy.log"
+    else
+        echo -e "  ${BOLD}Manage service:${NC}"
+        echo -e "    Status:  sudo systemctl status bambuddy"
+        echo -e "    Start:   sudo systemctl start bambuddy"
+        echo -e "    Stop:    sudo systemctl stop bambuddy"
+        echo -e "    Logs:    sudo journalctl -u bambuddy -f"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Update BamBuddy:${NC}"
+    echo -e "    cd $INSTALL_PATH && git pull && source venv/bin/activate"
+    echo -e "    pip install -r requirements.txt && cd frontend && npm ci && npm run build"
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        echo -e "    sudo systemctl restart bambuddy"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Documentation:${NC}  ${CYAN}https://wiki.bambuddy.cool${NC}"
+    echo ""
+}
+
+main "$@"

+ 5 - 0
requirements.txt

@@ -34,6 +34,11 @@ aiofiles>=23.0.0
 # QR Code generation
 # QR Code generation
 qrcode[pil]>=7.4.0
 qrcode[pil]>=7.4.0
 
 
+# STL Thumbnail Generation
+trimesh>=4.0.0
+matplotlib>=3.8.0
+fast-simplification>=0.1.0
+
 # System monitoring
 # System monitoring
 psutil>=6.0.0
 psutil>=6.0.0
 
 

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


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


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


+ 2 - 2
static/index.html

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

+ 1 - 0
test_frontend.sh

@@ -1,5 +1,6 @@
 #!/bin/sh
 #!/bin/sh
 
 
 cd frontend
 cd frontend
+npm run lint
 npm test
 npm test
 cd ..
 cd ..

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