소스 검색

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 달 전
부모
커밋
a965afa6cb
69개의 변경된 파일5655개의 추가작업 그리고 1597개의 파일을 삭제
  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)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
+- Tag management (rename/delete across all archives)
 
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
@@ -75,6 +76,8 @@
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
 - 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)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
@@ -86,7 +89,8 @@
 - Auto power-off after cooldown
 
 ### 📁 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
 - Option to create folder from ZIP filename
 - Folder structure with drag-and-drop
@@ -117,6 +121,7 @@
 - Print finish photo URL in notifications
 - HMS error alerts (AMS, nozzle, etc.)
 - Build plate detection alerts
+- Queue events (waiting, skipped, failed)
 
 ### 🔧 Integrations
 - [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
 
 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">
   Made with ❤️ for the 3D printing community
   <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,
         "bed_temperature": archive.bed_temperature,
         "nozzle_temperature": archive.nozzle_temperature,
+        "sliced_for_model": archive.sliced_for_model,
         "status": archive.status,
         "started_at": archive.started_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)
 async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
     """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
 
+    # Check printer exists first (before OpenCV check)
+    printer = await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
         raise HTTPException(
             status_code=503,
             detail="Plate detection not available. Install opencv-python-headless to enable.",
         )
 
-    printer = await get_printer_or_404(printer_id, db)
-
     # Check chamber light status
     light_warning = False
     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
 
+    # Check printer exists first (before OpenCV check)
+    printer = await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
         raise HTTPException(
             status_code=503,
             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
     state = printer_manager.get_status(printer_id)
     light_warning = state and not state.chamber_light
@@ -869,15 +871,15 @@ async def delete_plate_calibration(
         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():
         raise HTTPException(
             status_code=503,
             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)
     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
 
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
         return {
             "available": False,
@@ -918,9 +923,6 @@ async def get_plate_detection_status(
             "message": "OpenCV not installed",
         }
 
-    # Verify printer exists
-    await get_printer_or_404(printer_id, db)
-
     # Get chamber light status
     state = printer_manager.get_status(printer_id)
     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
 
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
         raise HTTPException(503, "Plate detection not available")
 
-    await get_printer_or_404(printer_id, db)
-
     detector = PlateDetector()
     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
 
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
     if not is_plate_detection_available():
         raise HTTPException(503, "Plate detection not available")
 
-    await get_printer_or_404(printer_id, db)
-
     detector = PlateDetector()
     thumbnail = detector.get_reference_thumbnail(printer_id, index)
 
@@ -997,11 +1001,12 @@ async def update_reference_label(
     """Update the label for a calibration reference."""
     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():
         raise HTTPException(503, "Plate detection not available")
 
-    await get_printer_or_404(printer_id, db)
-
     detector = PlateDetector()
     success = detector.update_reference_label(printer_id, index, label)
 
@@ -1020,11 +1025,12 @@ async def delete_reference(
     """Delete a specific calibration reference."""
     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():
         raise HTTPException(503, "Plate detection not available")
 
-    await get_printer_or_404(printer_id, db)
-
     detector = PlateDetector()
     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,
     AddToQueueResponse,
     AddToQueueResult,
+    BatchThumbnailRequest,
+    BatchThumbnailResponse,
+    BatchThumbnailResult,
     BulkDeleteRequest,
     BulkDeleteResponse,
     FileDuplicate,
@@ -43,6 +46,7 @@ from backend.app.schemas.library import (
     ZipExtractResult,
 )
 from backend.app.services.archive import ArchiveService, ThreeMFParser
+from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 
 logger = logging.getLogger(__name__)
 
@@ -621,6 +625,7 @@ async def list_files(
 async def upload_file(
     file: UploadFile = File(...),
     folder_id: int | None = None,
+    generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
 ):
     """Upload a file to the library."""
@@ -712,6 +717,11 @@ async def upload_file(
             # For image files, create a thumbnail from the image itself
             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
         library_file = LibraryFile(
             folder_id=folder_id,
@@ -749,6 +759,7 @@ async def extract_zip_file(
     folder_id: int | None = Query(default=None),
     preserve_structure: bool = Query(default=True),
     create_folder_from_zip: bool = Query(default=False),
+    generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
 ):
     """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)
         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
+        generate_stl_thumbnails: If True, generate thumbnails for STL files
     """
     import tempfile
     import zipfile
@@ -941,6 +953,11 @@ async def extract_zip_file(
                     elif ext.lower() in IMAGE_EXTENSIONS:
                         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
                     library_file = LibraryFile(
                         folder_id=target_folder_id,
@@ -994,6 +1011,118 @@ async def extract_zip_file(
             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 ============
 # 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",
     "maintenance_due": "Maintenance Due",
     "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,
         # Build plate detection
         "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_enabled": provider.quiet_hours_enabled,
         "quiet_hours_start": provider.quiet_hours_start,

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

@@ -2,13 +2,17 @@
 
 import json
 import logging
+import xml.etree.ElementTree as ET
+import zipfile
 from datetime import datetime
+from pathlib import Path
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
@@ -22,12 +26,75 @@ from backend.app.schemas.print_queue import (
     PrintQueueItemUpdate,
     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__)
 
 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:
     """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
@@ -38,10 +105,21 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
             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
     item_dict = {
         "id": item.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,
         "library_file_id": item.library_file_id,
         "position": item.position,
@@ -120,29 +198,71 @@ async def add_to_queue(
     db: AsyncSession = Depends(get_db),
 ):
     """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
     if not data.archive_id and not data.library_file_id:
         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)
     if data.printer_id is not None:
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
 
-    # Validate archive exists (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:
         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")
 
-    # 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:
         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")
 
-    # 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:
         result = await db.execute(
             select(func.max(PrintQueueItem.position))
@@ -150,7 +270,7 @@ async def add_to_queue(
             .where(PrintQueueItem.status == "pending")
         )
     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(
             select(func.max(PrintQueueItem.position))
             .where(PrintQueueItem.printer_id.is_(None))
@@ -160,6 +280,8 @@ async def add_to_queue(
 
     item = PrintQueueItem(
         printer_id=data.printer_id,
+        target_model=target_model_norm,
+        required_filament_types=required_filament_types,
         archive_id=data.archive_id,
         library_file_id=data.library_file_id,
         scheduled_time=data.scheduled_time,
@@ -185,7 +307,8 @@ async def add_to_queue(
     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}"
-    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
     try:
@@ -200,6 +323,29 @@ async def add_to_queue(
     except Exception:
         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)
 
 
@@ -287,12 +433,34 @@ async def update_queue_item(
 
     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)
     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"]))
         if not result.scalar_one_or_none():
             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
     if "ams_mapping" in update_data:
         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_temperature_high": getattr(p, "on_ams_ht_temperature_high", False),
                     "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_start": p.quiet_hours_start,
                     "quiet_hours_end": p.quiet_hours_end,
@@ -391,6 +398,7 @@ async def export_backup(
                     "schedule_on_time": plug.schedule_on_time,
                     "schedule_off_time": plug.schedule_off_time,
                     "show_in_switchbar": plug.show_in_switchbar,
+                    "show_on_printer_card": plug.show_on_printer_card,
                 }
             )
         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_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_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_start = provider_data.get("quiet_hours_start")
                     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_temperature_high=provider_data.get("on_ams_ht_temperature_high", False),
                     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_start=provider_data.get("quiet_hours_start"),
                     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_off_time = plug_data.get("schedule_off_time")
                     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
                 else:
                     skipped["smart_plugs"] += 1
@@ -1380,6 +1403,7 @@ async def import_backup(
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
                     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)
                 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")
 
         # 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
     if data.plug_type == "mqtt":
@@ -131,12 +143,48 @@ async def create_smart_plug(
 
 @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)):
-    """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))
-    plug = result.scalar_one_or_none()
-    if not plug:
+    plugs = result.scalars().all()
+
+    if not plugs:
         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
@@ -344,14 +392,29 @@ async def update_smart_plug(
             raise HTTPException(400, "Printer not found")
 
         # 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
     old_plug_type = plug.plug_type
@@ -508,6 +571,13 @@ async def control_smart_plug(
     plug.last_checked = datetime.utcnow()
     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
     if expected_state:
         try:
@@ -533,6 +603,37 @@ async def control_smart_plug(
     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)
 async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     """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:
                     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)
                 if queue_item.auto_off_after:
                     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)
     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
     status: Mapped[str] = mapped_column(String(20), default="completed")
     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
     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_enabled = Column(Boolean, default=False)
     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",
         "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
     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)
     archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
     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
     nozzle_temperature: int | None
 
+    sliced_for_model: str | None = None  # Printer model this file was sliced for
+
     status: str
     started_at: datetime | None
     completed_at: datetime | None

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

@@ -262,3 +262,32 @@ class ZipExtractResponse(BaseModel):
     folders_created: int
     files: list[ZipExtractResult]
     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
     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_enabled: bool = Field(default=False, description="Enable quiet hours")
     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
     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_enabled: bool | 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_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
     "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
@@ -136,6 +144,53 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "app_name": "Bambuddy",
         "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):
     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
     archive_id: int | None = None
     library_file_id: int | None = None
@@ -40,6 +42,7 @@ class PrintQueueItemCreate(BaseModel):
 
 class PrintQueueItemUpdate(BaseModel):
     printer_id: int | None = None
+    target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     position: int | None = None
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
@@ -59,6 +62,9 @@ class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemResponse(BaseModel):
     id: int
     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)
     library_file_id: int | None  # For queue items from library files
     position: int

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

@@ -14,7 +14,7 @@ class SmartPlugBase(BaseModel):
     password: str | None = None
 
     # 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)
     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_]+$")
@@ -59,8 +59,9 @@ class SmartPlugBase(BaseModel):
     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_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_on_printer_card: bool = True  # For scripts: show on printer card
 
     @model_validator(mode="after")
     def validate_plug_type_fields(self) -> "SmartPlugBase":
@@ -127,8 +128,9 @@ class SmartPlugUpdate(BaseModel):
     schedule_enabled: bool | None = None
     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$")
-    # Switchbar visibility
+    # Visibility options
     show_in_switchbar: bool | None = None
+    show_on_printer_card: bool | None = None
 
 
 class SmartPlugResponse(SmartPlugBase):
@@ -197,7 +199,7 @@ class HAEntity(BaseModel):
     entity_id: str
     friendly_name: str
     state: str | None = None
-    domain: str  # "switch", "light", "input_boolean"
+    domain: str  # "switch", "light", "input_boolean", "script"
 
 
 class HASensorEntity(BaseModel):

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

@@ -67,6 +67,19 @@ class ThreeMFParser:
                 content = zf.read("Metadata/slice_info.config").decode()
                 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)
                 plate = root.find(".//plate")
 
@@ -156,7 +169,7 @@ class ThreeMFParser:
             pass
 
     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
 
         try:
@@ -165,15 +178,25 @@ class ThreeMFParser:
             if not gcode_files:
                 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]
             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
             match = re.search(r";\s*total\s+layer\s+number[:\s]+(\d+)", header, re.IGNORECASE)
             if match:
                 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:
             pass
 
@@ -256,6 +279,12 @@ class ThreeMFParser:
                     elif isinstance(val, (int, float, str)):
                         self.metadata["nozzle_temperature"] = int(float(val))
                     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:
             pass
 
@@ -877,6 +906,7 @@ class ArchiveService:
             nozzle_diameter=metadata.get("nozzle_diameter"),
             bed_temperature=metadata.get("bed_temperature"),
             nozzle_temperature=metadata.get("nozzle_temperature"),
+            sliced_for_model=metadata.get("sliced_for_model"),
             makerworld_url=metadata.get("makerworld_url"),
             designer=metadata.get("designer"),
             status=status,

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

@@ -228,7 +228,7 @@ class HomeAssistantService:
             - domain: str
         """
         # Default domains for smart plug control
-        default_domains = {"switch", "light", "input_boolean"}
+        default_domains = {"switch", "light", "input_boolean", "script"}
 
         try:
             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."""
         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(
         self,
         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.
 """
 
+from __future__ import annotations
+
 import logging
 from pathlib import Path
 

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

@@ -4,7 +4,7 @@ import asyncio
 import logging
 from datetime import datetime
 
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 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.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.notification_service import notification_service
 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__)
 
@@ -62,13 +64,10 @@ class PrintScheduler:
             if not items:
                 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:
-                if item.printer_id in processed_printers:
-                    continue
-
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                     continue
@@ -77,46 +76,254 @@ class PrintScheduler:
                 if item.manual_start:
                     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:
-                            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
-                    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
 
-                # 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()
-                        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:
         """Check if a printer is connected and idle."""
@@ -141,15 +348,18 @@ class PrintScheduler:
 
         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
-        status = await tasmota_service.get_status(plug)
+        status = await service.get_status(plug)
         if not status.get("reachable"):
             logger.warning(f"Smart plug '{plug.name}' is not reachable")
             return False
 
         # Turn on if not already on
         if status.get("state") != "ON":
-            success = await tasmota_service.turn_on(plug)
+            success = await service.turn_on(plug)
             if not success:
                 logger.warning(f"Failed to turn on smart plug '{plug.name}'")
                 return False
@@ -218,7 +428,27 @@ class PrintScheduler:
             # Wait for cooldown (up to 10 minutes)
             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}")
-            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):
         """Upload file and start print for a queue item.
@@ -371,6 +601,16 @@ class PrintScheduler:
             item.completed_at = datetime.utcnow()
             await db.commit()
             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)
             return
 
@@ -411,6 +651,22 @@ class PrintScheduler:
             await db.commit()
             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
             try:
                 from backend.app.services.mqtt_relay import mqtt_relay
@@ -430,6 +686,16 @@ class PrintScheduler:
             item.completed_at = datetime.utcnow()
             await db.commit()
             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)
 
 

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

@@ -27,7 +27,7 @@ class SmartPlugManager:
         self._scheduler_task: asyncio.Task | None = None
         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.
 
         For HA plugs, configures the service with current settings from DB.
@@ -110,7 +110,7 @@ class SmartPlugManager:
             plugs = result.scalars().all()
 
             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
                 if plug.schedule_on_time == current_time:
@@ -166,7 +166,7 @@ class SmartPlugManager:
 
         # Turn on the plug
         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)
 
         if success:
@@ -195,6 +195,11 @@ class SmartPlugManager:
             logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
             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
         # This allows the user to investigate errors before power-off
         if status != "completed":
@@ -261,7 +266,7 @@ class SmartPlugManager:
                     self.name = f"plug_{plug_id}"
 
             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)
             logger.info(f"Turned off plug {plug_id} after time delay")
 
@@ -353,7 +358,7 @@ class SmartPlugManager:
                                 self.name = f"plug_{plug_id}"
 
                         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)
                         logger.info(
                             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
                         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)
                         if success:
                             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
         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
 
+        # 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
         tray_sub_brands = tray_data.get("tray_sub_brands", "")
         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."""
         response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
         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.message = "Plate appears empty"
         mock_result.needs_calibration = False
+        mock_result.debug_image = None
         mock_result.to_dict.return_value = {
             "is_empty": True,
             "confidence": 0.95,
@@ -294,7 +295,16 @@ class TestCameraAPI:
             "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
             response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
 
@@ -319,7 +329,10 @@ class TestCameraAPI:
         printer = await printer_factory()
 
         # 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)
             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."""
         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
         result = response.json()
@@ -374,8 +388,16 @@ class TestCameraAPI:
         """Verify get references returns proper structure."""
         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
         result = response.json()

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

@@ -1,5 +1,10 @@
 """Integration tests for Library API endpoints."""
 
+import io
+import tempfile
+import zipfile
+from pathlib import Path
+
 import pytest
 from httpx import AsyncClient
 
@@ -479,3 +484,234 @@ class TestLibraryZipExtractAPI:
         assert folder_response.status_code == 200
         folder = folder_response.json()
         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]
-Description=BamBuddy Print Archive
+Description=BamBuddy - Bambu Lab Print Management
+Documentation=https://github.com/maziggy/bambuddy
 After=network.target
 
 [Service]
 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
 
-# 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]
 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'
 
 export default defineConfig([
-  globalIgnores(['dist']),
+  globalIgnores(['dist', 'coverage']),
   {
     files: ['**/*.{ts,tsx}'],
     extends: [

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

@@ -2,7 +2,7 @@
  * 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 { setupServer } from 'msw/node';
 import type {

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

@@ -38,7 +38,11 @@ describe('EditArchiveModal', () => {
         return HttpResponse.json(mockProjects);
       }),
       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 }) => {
         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_ht_humidity_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_start: 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 { 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', () => {
   const mockOnClose = vi.fn();
 
   beforeEach(() => {
     vi.clearAllMocks();
     server.use(
-      http.get('/api/v1/printers/', () => {
-        return HttpResponse.json(mockPrinters);
-      }),
       http.post('/api/v1/archives/upload-bulk', async () => {
         return HttpResponse.json({
           uploaded: 1,
@@ -54,17 +46,6 @@ describe('UploadModal', () => {
       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', () => {
       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', () => {
     it('shows initial files when provided', () => {
       const initialFiles = [

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

@@ -347,8 +347,8 @@ describe('FileManagerPage', () => {
       render(<FileManagerPage />);
 
       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;
   bed_temperature: number | null;
   nozzle_temperature: number | null;
+  sliced_for_model: string | null;  // Printer model this file was sliced for
   status: string;
   started_at: string | null;
   completed_at: string | null;
@@ -334,6 +335,11 @@ export interface ArchiveStats {
   total_energy_cost: number;
 }
 
+export interface TagInfo {
+  name: string;
+  count: number;
+}
+
 export interface FailureAnalysis {
   period_days: number;
   total_prints: number;
@@ -842,7 +848,7 @@ export interface SmartPlug {
   name: string;
   plug_type: 'tasmota' | 'homeassistant' | 'mqtt';
   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)
   ha_power_entity: string | null;
   ha_energy_today_entity: string | null;
@@ -881,8 +887,9 @@ export interface SmartPlug {
   schedule_enabled: boolean;
   schedule_on_time: string | null;
   schedule_off_time: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar: boolean;
+  show_on_printer_card: boolean;  // For scripts: show on printer card
   // Status
   last_state: string | null;
   last_checked: string | null;
@@ -933,8 +940,9 @@ export interface SmartPlugCreate {
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar?: boolean;
+  show_on_printer_card?: boolean;
 }
 
 export interface SmartPlugUpdate {
@@ -978,8 +986,9 @@ export interface SmartPlugUpdate {
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar?: boolean;
+  show_on_printer_card?: boolean;
 }
 
 // Home Assistant entity for smart plug selection
@@ -987,7 +996,7 @@ export interface HAEntity {
   entity_id: string;
   friendly_name: string;
   state: string | null;
-  domain: string;  // "switch", "light", "input_boolean"
+  domain: string;  // "switch", "light", "input_boolean", "script"
 }
 
 // Home Assistant sensor entity for energy monitoring
@@ -1048,6 +1057,9 @@ export interface DiscoveredTasmotaDevice {
 export interface PrintQueueItem {
   id: number;
   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)
   archive_id: number | null;
   library_file_id: number | null;
@@ -1080,6 +1092,7 @@ export interface PrintQueueItem {
 
 export interface PrintQueueItemCreate {
   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
   archive_id?: number | null;
   library_file_id?: number | null;
@@ -1100,6 +1113,7 @@ export interface PrintQueueItemCreate {
 
 export interface PrintQueueItemUpdate {
   printer_id?: number | null;  // null = unassign
+  target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
   position?: number;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
@@ -1259,6 +1273,14 @@ export interface NotificationProvider {
   on_ams_ht_temperature_high: boolean;
   // Build plate detection
   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_enabled: boolean;
   quiet_hours_start: string | null;
@@ -1301,6 +1323,14 @@ export interface NotificationProviderCreate {
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   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_enabled?: boolean;
   quiet_hours_start?: string | null;
@@ -1336,6 +1366,14 @@ export interface NotificationProviderUpdate {
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   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_enabled?: boolean;
   quiet_hours_start?: string | null;
@@ -1948,6 +1986,17 @@ export const api = {
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
   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: () =>
     request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
   getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
@@ -2441,6 +2490,7 @@ export const api = {
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
   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) =>
     request<SmartPlug>('/smart-plugs/', {
       method: 'POST',
@@ -3046,11 +3096,17 @@ export const api = {
     return request<LibraryFileListItem[]>(`/library/files?${params}`);
   },
   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();
     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',
       body: formData,
     });
@@ -3064,7 +3120,8 @@ export const api = {
     file: File,
     folderId?: number | null,
     preserveStructure: boolean = true,
-    createFolderFromZip: boolean = false
+    createFolderFromZip: boolean = false,
+    generateStlThumbnails: boolean = true
   ): Promise<ZipExtractResponse> => {
     const formData = new FormData();
     formData.append('file', file);
@@ -3072,6 +3129,7 @@ export const api = {
     if (folderId) params.set('folder_id', String(folderId));
     params.set('preserve_structure', String(preserveStructure));
     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}`, {
       method: 'POST',
       body: formData,
@@ -3105,6 +3163,15 @@ export const api = {
       body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }),
     }),
   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[]) =>
     request<AddToQueueResponse>('/library/files/add-to-queue', {
       method: 'POST',
@@ -3429,6 +3496,21 @@ export interface ZipExtractResponse {
   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
 export interface AddToQueueResult {
   file_id: number;

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

@@ -68,30 +68,49 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
     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,
   });
 
-  // Extract unique tags from all archives
+  // Use existing tags prop if provided, otherwise use fetched tags
   const allTags = existingTags.length > 0
     ? 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
   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) => {
-    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);
     }
     // Clear any pending blur timeout to prevent hiding suggestions
@@ -342,7 +361,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               {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="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 className="p-2 flex flex-wrap gap-1.5">
                     {tagSuggestions.map((tag) => (

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

@@ -398,6 +398,88 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                 </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 */}
               <div className="space-y-2">
                 <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 {
   Printer as PrinterIcon,
@@ -9,6 +9,7 @@ import {
   Circle,
   RefreshCw,
   Wand2,
+  Users,
 } from 'lucide-react';
 import { api } from '../../api/client';
 import { getColorName } from '../../utils/colors';
@@ -16,7 +17,7 @@ import {
   normalizeColorForCompare,
   colorsAreSimilar,
 } from '../../utils/amsHelpers';
-import type { PrinterSelectorProps } from './types';
+import type { PrinterSelectorProps, AssignmentMode } from './types';
 import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
 
@@ -29,6 +30,16 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
   onAutoConfigurePrinter?: (printerId: number) => void;
   /** Callback to update printer config */
   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,
   onAutoConfigurePrinter,
   onUpdatePrinterConfig,
+  assignmentMode = 'printer',
+  onAssignmentModeChange,
+  targetModel,
+  onTargetModelChange,
+  slicedForModel,
 }: PrinterSelectorWithMappingProps) {
+  // State for showing all printers vs only matching model
+  const [showAllPrinters, setShowAllPrinters] = useState(false);
+
   // 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 &&
     selectedPrinterIds.length > 1 &&
@@ -285,8 +329,56 @@ export function PrinterSelector({
 
   return (
     <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">
           <span>
             {selectedCount === 0
@@ -316,7 +408,8 @@ export function PrinterSelector({
         </div>
       )}
 
-      {displayPrinters.map((printer) => {
+      {/* Printer list (only in printer mode) */}
+      {assignmentMode === 'printer' && displayPrinters.map((printer) => {
         const selected = isSelected(printer.id);
         const mappingResult = getPrinterMappingResult(printer.id);
         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">
           <AlertCircle className="w-3 h-3" />
           Select at least one printer
         </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>
   );
 }

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

@@ -1,6 +1,6 @@
 import { useState, useEffect, useMemo } from 'react';
 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 type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
 import { Card, CardContent } from '../Card';
@@ -19,6 +19,7 @@ import type {
   PrintOptions,
   ScheduleOptions,
   ScheduleType,
+  AssignmentMode,
 } 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)
   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)
   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));
@@ -145,6 +163,16 @@ export function PrintModal({
     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
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
     queryKey: ['archive-plates', archiveId],
@@ -304,14 +332,20 @@ export function PrintModal({
   const handleSubmit = async (e?: React.FormEvent) => {
     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');
       return;
     }
+    if (assignmentMode === 'model' && !targetModel) {
+      showToast('Please select a target printer model', 'error');
+      return;
+    }
 
     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[] } = {
       success: 0,
@@ -332,15 +366,16 @@ export function PrintModal({
     };
 
     // 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
       archive_id: isLibraryFile ? undefined : archiveId,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
       require_previous_success: scheduleOptions.requirePreviousSuccess,
       auto_off_after: scheduleOptions.autoOffAfter,
       manual_start: scheduleOptions.scheduleType === 'manual',
-      ams_mapping: getMappingForPrinter(printerId),
+      ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
       plate_id: selectedPlate,
       scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
         ? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -348,35 +383,24 @@ export function PrintModal({
       ...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 {
         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 = {
-            printer_id: printerId,
+            printer_id: null,
+            target_model: targetModel,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             auto_off_after: scheduleOptions.autoOffAfter,
             manual_start: scheduleOptions.scheduleType === 'manual',
-            ams_mapping: printerMapping,
+            ams_mapping: undefined,
             plate_id: selectedPlate,
             scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
               ? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -385,14 +409,63 @@ export function PrintModal({
           };
           await updateQueueMutation.mutateAsync(updateData);
         } 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++;
       } catch (error) {
         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
     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 {
-        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'] });
       onSuccess?.();
@@ -422,14 +499,18 @@ export function PrintModal({
   const canSubmit = useMemo(() => {
     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)
     if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
 
     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
   const getModalConfig = () => {
@@ -541,8 +622,29 @@ export function PrintModal({
               filamentReqs={effectiveFilamentReqs}
               onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
               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 */}
             {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">
@@ -565,7 +667,7 @@ export function PrintModal({
             )}
 
             {/* Print options */}
-            {(mode === 'reprint' || effectivePrinterCount > 0) && (
+            {(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
               <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
             )}
 

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

@@ -104,6 +104,13 @@ export interface PlatesResponse {
   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.
  */
@@ -115,6 +122,16 @@ export interface PrinterSelectorProps {
   allowMultiple?: boolean;
   /** Show inactive printers (for edit mode where original assignment may be inactive) */
   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 { 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 { api } 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 })) || []
   );
   const [isDragging, setIsDragging] = useState(false);
-  const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
   const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
 
   // Close on Escape key
@@ -39,14 +38,9 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
     return () => window.removeEventListener('keydown', handleKeyDown);
   }, [onClose]);
 
-  const { data: printers } = useQuery({
-    queryKey: ['printers'],
-    queryFn: api.getPrinters,
-  });
-
   const uploadMutation = useMutation({
     mutationFn: (filesToUpload: File[]) =>
-      api.uploadArchivesBulk(filesToUpload, selectedPrinter),
+      api.uploadArchivesBulk(filesToUpload),
     onSuccess: (result) => {
       setUploadResult(result);
       queryClient.invalidateQueries({ queryKey: ['archives'] });
@@ -200,26 +194,11 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
             </div>
           </div>
 
-          {/* Optional Printer Selection */}
+          {/* Info about printer model extraction */}
           <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>
 
           {/* File List */}

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

@@ -43,6 +43,7 @@ import {
   FolderKanban,
   ChevronLeft,
   ChevronRight,
+  Settings,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
@@ -66,6 +67,7 @@ import { ProjectPageModal } from '../components/ProjectPageModal';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
+import { TagManagementModal } from '../components/TagManagementModal';
 import { useToast } from '../contexts/ToastContext';
 
 function formatFileSize(bytes: number): string {
@@ -797,6 +799,12 @@ function ArchiveCard({
               {archive.object_count} object{archive.object_count > 1 ? 's' : ''}
             </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 && (
             <div className="flex items-center gap-1.5 col-span-2">
               <span className="text-bambu-gray text-xs">{archive.filament_type}</span>
@@ -1612,9 +1620,20 @@ function ArchiveListRow({
               </Link>
             )}
           </div>
-          {archive.filament_type && (
+          {(archive.filament_type || archive.sliced_for_model) && (
             <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 && (
                 <div className="flex items-center gap-0.5 flex-wrap">
                   {archive.filament_color.split(',').map((color, i) => (
@@ -1989,6 +2008,7 @@ export function ArchivesPage() {
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [showCompareModal, setShowCompareModal] = useState(false);
+  const [showTagManagement, setShowTagManagement] = useState(false);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
 
   // Clear highlight after 5 seconds and scroll to highlighted element
@@ -2620,6 +2640,13 @@ export function ArchivesPage() {
                     </option>
                   ))}
                 </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 className="flex items-center gap-2 flex-shrink-0">
@@ -2755,7 +2782,7 @@ export function ArchivesPage() {
             <ArchiveCard
               key={archive.id}
               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)}
               onSelect={toggleSelect}
               selectionMode={selectionMode}
@@ -2782,7 +2809,7 @@ export function ArchivesPage() {
               <ArchiveListRow
                 key={archive.id}
                 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)}
                 onSelect={toggleSelect}
                 selectionMode={selectionMode}
@@ -2848,6 +2875,11 @@ export function ArchivesPage() {
           }}
         />
       )}
+
+      {/* Tag Management Modal */}
+      {showTagManagement && (
+        <TagManagementModal onClose={() => setShowTagManagement(false)} />
+      )}
     </div>
   );
 }

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

@@ -35,6 +35,7 @@ import {
   Printer,
   Pencil,
   Play,
+  Image,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type {
@@ -49,6 +50,7 @@ import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
+import { useIsMobile } from '../hooks/useIsMobile';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
@@ -427,6 +429,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
   const [isUploading, setIsUploading] = useState(false);
   const [preserveZipStructure, setPreserveZipStructure] = useState(true);
   const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
+  const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
   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 hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
 
   const handleUpload = async () => {
     if (files.length === 0) return;
@@ -482,7 +486,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
       try {
         if (files[i].isZip) {
           // 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) =>
             prev.map((f, idx) =>
               idx === i
@@ -497,7 +501,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
           );
         } else {
           // Regular file upload
-          await api.uploadLibraryFile(files[i].file, folderId);
+          await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails);
           setFiles((prev) =>
             prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
           );
@@ -596,6 +600,32 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
             </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 */}
           {files.length > 0 && (
             <div className="max-h-48 overflow-y-auto space-y-2">
@@ -826,15 +856,18 @@ function isSlicedFilename(filename: string): boolean {
 interface FileCardProps {
   file: LibraryFileListItem;
   isSelected: boolean;
+  isMobile: boolean;
   onSelect: (id: number) => void;
   onDelete: (id: number) => void;
   onDownload: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onPrint?: (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);
 
   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">
         {file.thumbnail_path ? (
           <img
-            src={api.getLibraryFileThumbnailUrl(file.id)}
+            src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersion ? `?v=${thumbnailVersion}` : ''}`}
             alt={file.filename}
             className="w-full h-full object-cover"
           />
@@ -890,7 +923,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
       </div>
 
       {/* 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
           onClick={() => setShowActions(!showActions)}
           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
                 </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
                 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); }}
@@ -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 ${
         isSelected
           ? '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" />}
       </div>
@@ -979,6 +1021,7 @@ export function FileManagerPage() {
   const [printFile, setPrintFile] = 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 [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
@@ -1032,11 +1075,20 @@ export function FileManagerPage() {
     };
   }, [isResizing, sidebarWidth]);
 
-  // Filter and sort state
+  // Filter and sort state (persist sort preferences to localStorage)
   const [searchQuery, setSearchQuery] = useState('');
   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)
   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)
   const isSlicedFile = useCallback((filename: string) => {
     const lower = filename.toLowerCase();
@@ -1333,7 +1431,7 @@ export function FileManagerPage() {
   const isLoading = foldersLoading || filesLoading;
 
   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 */}
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
         <div>
@@ -1369,6 +1467,19 @@ export function FileManagerPage() {
               <List className="w-4 h-4" />
             </button>
           </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)}>
             <FolderPlus className="w-4 h-4 mr-2" />
             New Folder
@@ -1396,7 +1507,7 @@ export function FileManagerPage() {
 
       {/* Stats bar */}
       {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">
             <File className="w-4 h-4 text-bambu-green" />
             <span className="text-bambu-gray">Files:</span>
@@ -1412,7 +1523,7 @@ export function FileManagerPage() {
             <span className="text-bambu-gray">Size:</span>
             <span className="text-white font-medium">{formatFileSize(stats.total_size_bytes)}</span>
           </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={`font-medium ${isDiskSpaceLow ? 'text-amber-500' : 'text-white'}`}>
               {formatFileSize(stats.disk_free_bytes)}
@@ -1422,11 +1533,40 @@ export function FileManagerPage() {
       )}
 
       {/* 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
           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` }}
         >
           {/* Resize handle - drag to resize, double-click to reset */}
@@ -1500,12 +1640,12 @@ export function FileManagerPage() {
         </div>
 
         {/* 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 && (
-            <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 */}
-              <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" />
                 <input
                   type="text"
@@ -1518,7 +1658,7 @@ export function FileManagerPage() {
 
               {/* Type filter */}
               <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
                   value={filterType}
                   onChange={(e) => setFilterType(e.target.value)}
@@ -1537,17 +1677,25 @@ export function FileManagerPage() {
               <div className="flex items-center gap-2">
                 <select
                   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"
                 >
-                  <option value="date">Date</option>
                   <option value="name">Name</option>
+                  <option value="date">Date</option>
                   <option value="size">Size</option>
                   <option value="type">Type</option>
                   <option value="prints">Prints</option>
                 </select>
                 <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"
                   title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
                 >
@@ -1561,16 +1709,16 @@ export function FileManagerPage() {
 
               {/* Results count */}
               {(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
                 </span>
               )}
             </div>
           )}
 
-          {/* Selection toolbar */}
+          {/* Selection toolbar - sticky on mobile below search bar */}
           {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 */}
               {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
                 <Button
@@ -1578,8 +1726,8 @@ export function FileManagerPage() {
                   size="sm"
                   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
@@ -1587,8 +1735,8 @@ export function FileManagerPage() {
                   size="sm"
                   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>
               )}
 
@@ -1597,57 +1745,60 @@ export function FileManagerPage() {
                   <span className="text-sm text-bambu-gray ml-2">
                     {selectedFiles.length} selected
                   </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
-                      variant="primary"
+                      variant="secondary"
                       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>
-                  )}
-                  {selectedSlicedFiles.length > 0 && (
                     <Button
-                      variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
+                      variant="danger"
                       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
-                    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>
@@ -1693,28 +1844,31 @@ export function FileManagerPage() {
               </Button>
             </div>
           ) : 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">
                 {filteredAndSortedFiles.map((file) => (
                   <FileCard
                     key={file.id}
                     file={file}
                     isSelected={selectedFiles.includes(file.id)}
+                    isMobile={isMobile}
                     onSelect={handleFileSelect}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDownload={handleDownload}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
                     onPrint={setPrintFile}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
+                    onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
+                    thumbnailVersion={thumbnailVersions[file.id]}
                   />
                 ))}
               </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">
-                {/* 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>Name</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">
                           {file.thumbnail_path ? (
                             <img
-                              src={api.getLibraryFileThumbnailUrl(file.id)}
+                              src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
                               alt=""
                               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="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
                               <img
-                                src={api.getLibraryFileThumbnailUrl(file.id)}
+                                src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
                                 alt={file.filename}
                                 className="w-full h-full object-contain"
                               />
@@ -1822,6 +1976,16 @@ export function FileManagerPage() {
                       >
                         <Pencil className="w-4 h-4" />
                       </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
                         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"

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

@@ -1093,6 +1093,12 @@ function PrinterCard({
     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)
   const { data: plugStatus } = useQuery({
     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
   const stopPrintMutation = useMutation({
     mutationFn: () => api.stopPrint(printer.id),
@@ -2683,17 +2698,39 @@ function PrinterCard({
                 </button>
               </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>
         )}
 
         {/* Connection Info & Actions - hidden in compact mode */}
         {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">
               <p>{printer.ip_address}</p>
               <p className="truncate">{printer.serial_number}</p>
             </div>
-            <div className="flex items-center gap-2">
+            <div className="flex items-center gap-2 flex-wrap">
               {/* Chamber Light Toggle */}
               <Button
                 variant="secondary"
@@ -4487,12 +4524,12 @@ export function PrintersPage() {
 
   return (
     <div className="p-4 md:p-8">
-      <div className="flex items-center justify-between mb-6">
+      <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
         <div>
           <h1 className="text-2xl font-bold text-white">Printers</h1>
           <StatusSummaryBar printers={printers} />
         </div>
-        <div className="flex items-center gap-3">
+        <div className="flex items-center gap-2 sm:gap-3 flex-wrap">
           {/* Sort dropdown */}
           <div className="flex items-center gap-1">
             <select

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

@@ -77,7 +77,17 @@ function formatRelativeTime(dateString: string | null, timeFormat: 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 = {
     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' },
@@ -399,9 +409,13 @@ function SortableQueueItem({
           </div>
 
           <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" />
-              {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>
             {item.print_time_seconds && (
               <span className="flex items-center gap-1.5">
@@ -448,6 +462,14 @@ function SortableQueueItem({
             </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 */}
           {item.error_message && (
             <p className="text-xs text-red-400 mt-2 flex items-center gap-1">
@@ -458,7 +480,7 @@ function SortableQueueItem({
         </div>
 
         {/* Status badge */}
-        <StatusBadge status={item.status} />
+        <StatusBadge status={item.status} waitingReason={item.waiting_reason} />
 
         {/* Actions */}
         <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 { 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 { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -548,14 +548,16 @@ export function SettingsPage() {
   // Local state for camera URL inputs (to avoid saving on every keystroke)
   const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});
   const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
+  const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());
 
   // Initialize local camera URLs from printer data
   useEffect(() => {
     if (printers) {
       const urls: Record<number, string> = {};
       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;
+          initializedPrinterUrlsRef.current.add(p.id);
         }
       });
       if (Object.keys(urls).length > 0) {
@@ -742,17 +744,20 @@ export function SettingsPage() {
                   <Globe className="w-4 h-4 inline mr-1" />
                   {t('settings.language')}
                 </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">
                   {t('settings.languageDescription')}
                 </p>
@@ -761,17 +766,20 @@ export function SettingsPage() {
                 <label className="block text-sm text-bambu-gray mb-1">
                   {t('settings.defaultView')}
                 </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">
                   {t('settings.defaultViewDescription')}
                 </p>
@@ -781,48 +789,57 @@ export function SettingsPage() {
                   <label className="block text-sm text-bambu-gray mb-1">
                     Date Format
                   </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>
                   <label className="block text-sm text-bambu-gray mb-1">
                     Time Format
                   </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>
                 <label className="block text-sm text-bambu-gray mb-1">
                   Default Printer
                 </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">
                   Pre-select this printer for uploads, reprints, and other operations.
                 </p>
@@ -1647,7 +1664,7 @@ export function SettingsPage() {
             </CardHeader>
             <CardContent className="space-y-4">
               <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>
 
               <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
 qrcode[pil]>=7.4.0
 
+# STL Thumbnail Generation
+trimesh>=4.0.0
+matplotlib>=3.8.0
+fast-simplification>=0.1.0
+
 # System monitoring
 psutil>=6.0.0
 

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-BJQC4Kk-.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-C1mIxrzF.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-Cs7zD_Fu.css


+ 2 - 2
static/index.html

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

+ 1 - 0
test_frontend.sh

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

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.