Browse Source

Add external folder mounting for File Manager (#124)

  Host directories (NAS, USB, network shares) can now be mounted
  into the File Manager without copying files. Files are indexed
  into the database on scan but read directly from their original
  location. Supports read-only mode, hidden file filtering, and
  automatic thumbnail extraction for 3MF/STL/gcode.

  - POST /library/folders/external — create with path validation
  - POST /library/folders/{id}/scan — discover/sync files
  - Block uploads, moves, and deletes for read-only external folders
  - Never delete actual files from external paths (DB-only removal)
  - Purple folder icon + info bar with rescan button in UI
  - i18n for all 7 languages
  - 19 backend + 11 frontend tests
maziggy 2 months ago
parent
commit
82278e8d93

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ## [0.2.3b1] - Unreleased
 ## [0.2.3b1] - Unreleased
 
 
+### New Features
+- **External Folder Mounting for File Manager** ([#124](https://github.com/maziggy/bambuddy/issues/124)) — Host directories (NAS shares, USB drives, network storage) can now be mounted into the File Manager without copying files. Click "Link External" to point at a Docker bind-mounted path. Files are indexed into the database on scan but accessed directly from their original location — nothing is copied. Supports read-only mode (default, blocks uploads/moves/deletes), hidden file filtering, and automatic thumbnail extraction for 3MF, STL, and gcode files. External folders show a distinct icon and info bar with a rescan button. Deleting an external folder only removes the database index, never the actual files. Requested by @S1N4X.
+
 ### Fixed
 ### Fixed
 - **SpoolBuddy Update Check Always Shows "Up to Date"** — The SpoolBuddy daemon update check compared the device's firmware version against GitHub releases instead of the running Bambuddy backend version. This meant the check could incorrectly report "up to date" even when the daemon was behind. Fixed by comparing directly against `APP_VERSION` from the backend config.
 - **SpoolBuddy Update Check Always Shows "Up to Date"** — The SpoolBuddy daemon update check compared the device's firmware version against GitHub releases instead of the running Bambuddy backend version. This meant the check could incorrectly report "up to date" even when the daemon was behind. Fixed by comparing directly against `APP_VERSION` from the backend config.
 - **SpoolBuddy Updates Now Use SSH** — Replaced the fragile self-update mechanism (daemon pulls its own code via git, permission errors on `.git/`, hardcoded `main` branch) with SSH-based updates driven by the Bambuddy backend. Bambuddy now SSHes into the SpoolBuddy Pi and runs git fetch/checkout, pip install, systemctl restart, and kiosk browser restart remotely. Updates automatically use the same branch as Bambuddy. SSH key pairing is fully automatic — Bambuddy generates an ED25519 keypair and includes the public key in the device registration response; the daemon deploys it to `authorized_keys` on first connect. The install script creates the `spoolbuddy` user with a bash shell and sudoers entries for daemon and kiosk restart. A "Force Update" button allows re-deploying even when versions match. The SSH public key is also shown in SpoolBuddy Settings → Updates → SSH Setup for manual pairing if needed.
 - **SpoolBuddy Updates Now Use SSH** — Replaced the fragile self-update mechanism (daemon pulls its own code via git, permission errors on `.git/`, hardcoded `main` branch) with SSH-based updates driven by the Bambuddy backend. Bambuddy now SSHes into the SpoolBuddy Pi and runs git fetch/checkout, pip install, systemctl restart, and kiosk browser restart remotely. Updates automatically use the same branch as Bambuddy. SSH key pairing is fully automatic — Bambuddy generates an ED25519 keypair and includes the public key in the device registration response; the daemon deploys it to `authorized_keys` on first connect. The install script creates the `spoolbuddy` user with a bash shell and sudoers entries for daemon and kiosk restart. A "Force Update" button allows re-deploying even when versions match. The SSH public key is also shown in SpoolBuddy Settings → Updates → SSH Setup for manual pairing if needed.

+ 1 - 0
README.md

@@ -122,6 +122,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 
 
 ### 📁 File Manager (Library)
 ### 📁 File Manager (Library)
 - Upload and organize sliced files (3MF, gcode, STL)
 - Upload and organize sliced files (3MF, gcode, STL)
+- **External folder mounting** - Mount host directories (NAS, USB, network shares) without copying files
 - **STL thumbnail generation** - Auto-generate previews for STL files on upload or batch generate for existing files
 - **STL thumbnail generation** - Auto-generate previews for STL files on upload or batch generate for existing files
 - ZIP file extraction with folder structure preservation
 - ZIP file extraction with folder structure preservation
 - Option to create folder from ZIP filename
 - Option to create folder from ZIP filename

+ 326 - 21
backend/app/api/routes/library.py

@@ -39,6 +39,7 @@ from backend.app.schemas.library import (
     BatchThumbnailResult,
     BatchThumbnailResult,
     BulkDeleteRequest,
     BulkDeleteRequest,
     BulkDeleteResponse,
     BulkDeleteResponse,
+    ExternalFolderCreate,
     FileDuplicate,
     FileDuplicate,
     FileListResponse,
     FileListResponse,
     FileMoveRequest,
     FileMoveRequest,
@@ -278,6 +279,9 @@ async def list_folders(
             archive_id=folder.archive_id,
             archive_id=folder.archive_id,
             project_name=project_name,
             project_name=project_name,
             archive_name=archive_name,
             archive_name=archive_name,
+            is_external=folder.is_external,
+            external_path=folder.external_path,
+            external_readonly=folder.external_readonly,
             file_count=file_counts.get(folder.id, 0),
             file_count=file_counts.get(folder.id, 0),
             children=[],
             children=[],
         )
         )
@@ -326,6 +330,10 @@ async def get_folders_by_project(
                 archive_id=folder.archive_id,
                 archive_id=folder.archive_id,
                 project_name=project_name,
                 project_name=project_name,
                 archive_name=None,
                 archive_name=None,
+                is_external=folder.is_external,
+                external_path=folder.external_path,
+                external_readonly=folder.external_readonly,
+                external_show_hidden=folder.external_show_hidden,
                 file_count=file_count,
                 file_count=file_count,
                 created_at=folder.created_at,
                 created_at=folder.created_at,
                 updated_at=folder.updated_at,
                 updated_at=folder.updated_at,
@@ -367,6 +375,10 @@ async def get_folders_by_archive(
                 archive_id=folder.archive_id,
                 archive_id=folder.archive_id,
                 project_name=None,
                 project_name=None,
                 archive_name=archive_name,
                 archive_name=archive_name,
+                is_external=folder.is_external,
+                external_path=folder.external_path,
+                external_readonly=folder.external_readonly,
+                external_show_hidden=folder.external_show_hidden,
                 file_count=file_count,
                 file_count=file_count,
                 created_at=folder.created_at,
                 created_at=folder.created_at,
                 updated_at=folder.updated_at,
                 updated_at=folder.updated_at,
@@ -426,6 +438,10 @@ async def create_folder(
         archive_id=folder.archive_id,
         archive_id=folder.archive_id,
         project_name=project_name,
         project_name=project_name,
         archive_name=archive_name,
         archive_name=archive_name,
+        is_external=folder.is_external,
+        external_path=folder.external_path,
+        external_readonly=folder.external_readonly,
+        external_show_hidden=folder.external_show_hidden,
         file_count=0,
         file_count=0,
         created_at=folder.created_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
         updated_at=folder.updated_at,
@@ -464,6 +480,10 @@ async def get_folder(
         archive_id=folder.archive_id,
         archive_id=folder.archive_id,
         project_name=project_name,
         project_name=project_name,
         archive_name=archive_name,
         archive_name=archive_name,
+        is_external=folder.is_external,
+        external_path=folder.external_path,
+        external_readonly=folder.external_readonly,
+        external_show_hidden=folder.external_show_hidden,
         file_count=file_count,
         file_count=file_count,
         created_at=folder.created_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
         updated_at=folder.updated_at,
@@ -556,6 +576,10 @@ async def update_folder(
         archive_id=folder.archive_id,
         archive_id=folder.archive_id,
         project_name=project_name,
         project_name=project_name,
         archive_name=archive_name,
         archive_name=archive_name,
+        is_external=folder.is_external,
+        external_path=folder.external_path,
+        external_readonly=folder.external_readonly,
+        external_show_hidden=folder.external_show_hidden,
         file_count=file_count,
         file_count=file_count,
         created_at=folder.created_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
         updated_at=folder.updated_at,
@@ -579,6 +603,9 @@ async def delete_folder(
     if not folder:
     if not folder:
         raise HTTPException(status_code=404, detail="Folder not found")
         raise HTTPException(status_code=404, detail="Folder not found")
 
 
+    # External folders: only remove DB records, never delete files from external path
+    is_ext = folder.is_external
+
     # Get all files in this folder and subfolders to delete from disk
     # Get all files in this folder and subfolders to delete from disk
     async def get_all_file_ids(fid: int) -> list[int]:
     async def get_all_file_ids(fid: int) -> list[int]:
         """Recursively get all file IDs in a folder tree."""
         """Recursively get all file IDs in a folder tree."""
@@ -586,20 +613,21 @@ async def delete_folder(
 
 
         # Get files in this folder
         # Get files in this folder
         files_result = await db.execute(
         files_result = await db.execute(
-            select(LibraryFile.id, LibraryFile.file_path, LibraryFile.thumbnail_path).where(
+            select(LibraryFile.id, LibraryFile.file_path, LibraryFile.thumbnail_path, LibraryFile.is_external).where(
                 LibraryFile.folder_id == fid
                 LibraryFile.folder_id == fid
             )
             )
         )
         )
-        for file_id, file_path, thumb_path in files_result.all():
-            file_ids.append(file_id)
-            # Delete actual files
-            try:
-                if file_path and os.path.exists(file_path):
-                    os.remove(file_path)
-                if thumb_path and os.path.exists(thumb_path):
-                    os.remove(thumb_path)
-            except OSError as e:
-                logger.warning("Failed to delete file: %s", e)
+        for fid_val, file_path, thumb_path, file_is_ext in files_result.all():
+            file_ids.append(fid_val)
+            # Only delete non-external files from disk
+            if not is_ext and not file_is_ext:
+                try:
+                    if file_path and os.path.exists(file_path):
+                        os.remove(file_path)
+                    if thumb_path and os.path.exists(thumb_path):
+                        os.remove(thumb_path)
+                except OSError as e:
+                    logger.warning("Failed to delete file: %s", e)
 
 
         # Get child folders and recurse
         # Get child folders and recurse
         children_result = await db.execute(select(LibraryFolder.id).where(LibraryFolder.parent_id == fid))
         children_result = await db.execute(select(LibraryFolder.id).where(LibraryFolder.parent_id == fid))
@@ -616,6 +644,266 @@ async def delete_folder(
     return {"status": "success", "message": "Folder deleted"}
     return {"status": "success", "message": "Folder deleted"}
 
 
 
 
+# ============ External Folder Endpoints ============
+
+# Blocked system directories that cannot be mounted
+_BLOCKED_PREFIXES = (
+    "/proc",
+    "/sys",
+    "/dev",
+    "/run",
+    "/boot",
+    "/sbin",
+    "/bin",
+    "/usr/sbin",
+    "/usr/bin",
+    "/lib",
+    "/etc",
+)
+
+# Supported file extensions for external folder scanning
+_SCANNABLE_EXTENSIONS = {
+    ".3mf",
+    ".gcode",
+    ".gcode.3mf",
+    ".stl",
+    ".obj",
+    ".step",
+    ".stp",
+    ".png",
+    ".jpg",
+    ".jpeg",
+    ".gif",
+    ".webp",
+    ".svg",
+}
+
+
+def _validate_external_path(path_str: str) -> Path:
+    """Validate an external path is safe to mount."""
+    path = Path(path_str).resolve()
+
+    if not path.is_absolute():
+        raise HTTPException(status_code=400, detail="Path must be absolute")
+
+    for prefix in _BLOCKED_PREFIXES:
+        if str(path).startswith(prefix):
+            raise HTTPException(status_code=400, detail=f"Cannot mount system directory: {prefix}")
+
+    if not path.exists():
+        raise HTTPException(status_code=400, detail=f"Path does not exist: {path}")
+
+    if not path.is_dir():
+        raise HTTPException(status_code=400, detail=f"Path is not a directory: {path}")
+
+    # Check readability
+    if not os.access(path, os.R_OK):
+        raise HTTPException(status_code=400, detail=f"Path is not readable: {path}")
+
+    return path
+
+
+@router.post("/folders/external", response_model=FolderResponse)
+async def create_external_folder(
+    data: ExternalFolderCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
+):
+    """Create an external folder that points to a host directory."""
+    resolved = _validate_external_path(data.external_path)
+
+    # Check no other external folder already points to this path
+    existing = await db.execute(
+        select(LibraryFolder).where(
+            LibraryFolder.is_external.is_(True),
+            LibraryFolder.external_path == str(resolved),
+        )
+    )
+    if existing.scalar_one_or_none():
+        raise HTTPException(status_code=409, detail="An external folder already exists for this path")
+
+    # Verify parent exists if specified
+    if data.parent_id is not None:
+        parent_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.parent_id))
+        if not parent_result.scalar_one_or_none():
+            raise HTTPException(status_code=404, detail="Parent folder not found")
+
+    folder = LibraryFolder(
+        name=data.name,
+        parent_id=data.parent_id,
+        is_external=True,
+        external_path=str(resolved),
+        external_readonly=data.readonly,
+        external_show_hidden=data.show_hidden,
+    )
+    db.add(folder)
+    await db.commit()
+    await db.refresh(folder)
+
+    return FolderResponse(
+        id=folder.id,
+        name=folder.name,
+        parent_id=folder.parent_id,
+        project_id=None,
+        archive_id=None,
+        is_external=True,
+        external_path=folder.external_path,
+        external_readonly=folder.external_readonly,
+        external_show_hidden=folder.external_show_hidden,
+        file_count=0,
+        created_at=folder.created_at,
+        updated_at=folder.updated_at,
+    )
+
+
+@router.post("/folders/{folder_id}/scan")
+async def scan_external_folder(
+    folder_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
+):
+    """Scan an external folder and sync files to the database.
+
+    Discovers new files, removes DB entries for deleted files.
+    Does not copy files — stores the external path directly.
+    """
+    result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
+    folder = result.scalar_one_or_none()
+
+    if not folder:
+        raise HTTPException(status_code=404, detail="Folder not found")
+    if not folder.is_external or not folder.external_path:
+        raise HTTPException(status_code=400, detail="Not an external folder")
+
+    ext_path = Path(folder.external_path)
+    if not ext_path.exists() or not ext_path.is_dir():
+        raise HTTPException(status_code=400, detail=f"External path is not accessible: {folder.external_path}")
+
+    # Get existing DB files for this folder
+    existing_result = await db.execute(
+        select(LibraryFile).where(LibraryFile.folder_id == folder_id, LibraryFile.is_external.is_(True))
+    )
+    existing_files = {f.file_path: f for f in existing_result.scalars().all()}
+
+    # Scan the directory
+    added = 0
+    removed = 0
+    found_paths = set()
+
+    for dirpath, _dirnames, filenames in os.walk(ext_path):
+        for filename in filenames:
+            # Skip hidden files unless configured
+            if not folder.external_show_hidden and filename.startswith("."):
+                continue
+
+            filepath = Path(dirpath) / filename
+            ext = filepath.suffix.lower()
+
+            # Check for compound extensions like .gcode.3mf
+            if ext not in _SCANNABLE_EXTENSIONS:
+                # Check compound
+                compound = "".join(filepath.suffixes[-2:]).lower() if len(filepath.suffixes) >= 2 else ""
+                if compound not in _SCANNABLE_EXTENSIONS:
+                    continue
+
+            # Resolve symlinks and ensure still under external_path
+            try:
+                real_path = filepath.resolve()
+                real_path.relative_to(ext_path.resolve())
+            except (ValueError, OSError):
+                continue  # Symlink escapes the external dir
+
+            file_path_str = str(filepath)
+            found_paths.add(file_path_str)
+
+            if file_path_str in existing_files:
+                continue  # Already tracked
+
+            # Get file info
+            try:
+                stat = filepath.stat()
+            except OSError:
+                continue
+
+            file_type = ext[1:] if ext else "unknown"
+            # For compound extensions, use the meaningful part
+            if file_type in ("3mf",) and len(filepath.suffixes) >= 2:
+                inner = filepath.suffixes[-2].lower()
+                if inner == ".gcode":
+                    file_type = "gcode.3mf"
+
+            # Extract thumbnail for 3mf files
+            thumbnail_path = None
+            file_metadata = None
+            if file_type == "3mf":
+                try:
+                    parser = ThreeMFParser(str(filepath))
+                    meta = parser.parse()
+                    if meta:
+                        file_metadata = meta
+                    thumb_data = parser.extract_thumbnail()
+                    if thumb_data:
+                        thumb_dir = get_library_thumbnails_dir()
+                        thumb_filename = f"{uuid.uuid4().hex}.png"
+                        thumb_full = thumb_dir / thumb_filename
+                        thumb_full.write_bytes(thumb_data)
+                        thumbnail_path = to_relative_path(thumb_full)
+                except Exception as e:
+                    logger.debug("Failed to extract metadata from external 3mf %s: %s", filepath, e)
+
+            # Generate thumbnail for STL files
+            if file_type == "stl" and thumbnail_path is None:
+                try:
+                    thumb_dir = get_library_thumbnails_dir()
+                    thumb_result = generate_stl_thumbnail(str(filepath), str(thumb_dir))
+                    if thumb_result:
+                        thumbnail_path = to_relative_path(Path(thumb_result))
+                except Exception as e:
+                    logger.debug("Failed to generate STL thumbnail for external %s: %s", filepath, e)
+
+            # Extract gcode thumbnail
+            if file_type == "gcode" and thumbnail_path is None:
+                thumb_data = extract_gcode_thumbnail(filepath)
+                if thumb_data:
+                    thumb_dir = get_library_thumbnails_dir()
+                    thumb_filename = f"{uuid.uuid4().hex}.png"
+                    thumb_full = thumb_dir / thumb_filename
+                    thumb_full.write_bytes(thumb_data)
+                    thumbnail_path = to_relative_path(thumb_full)
+
+            db_file = LibraryFile(
+                folder_id=folder_id,
+                is_external=True,
+                filename=filename,
+                file_path=file_path_str,
+                file_type=file_type,
+                file_size=stat.st_size,
+                file_hash=None,  # Skip hashing external files for performance
+                thumbnail_path=thumbnail_path,
+                file_metadata=file_metadata,
+            )
+            db.add(db_file)
+            added += 1
+
+    # Remove DB entries for files that no longer exist on disk
+    for path_str, db_file in existing_files.items():
+        if path_str not in found_paths:
+            # Clean up thumbnail if we generated one
+            if db_file.thumbnail_path:
+                try:
+                    abs_thumb = to_absolute_path(db_file.thumbnail_path)
+                    if abs_thumb and abs_thumb.exists():
+                        abs_thumb.unlink()
+                except OSError:
+                    pass
+            await db.delete(db_file)
+            removed += 1
+
+    await db.commit()
+
+    return {"status": "success", "added": added, "removed": removed}
+
+
 # ============ File Endpoints ============
 # ============ File Endpoints ============
 
 
 
 
@@ -678,6 +966,7 @@ async def list_files(
             FileListResponse(
             FileListResponse(
                 id=f.id,
                 id=f.id,
                 folder_id=f.folder_id,
                 folder_id=f.folder_id,
+                is_external=f.is_external,
                 filename=f.filename,
                 filename=f.filename,
                 file_type=f.file_type,
                 file_type=f.file_type,
                 file_size=f.file_size,
                 file_size=f.file_size,
@@ -719,8 +1008,11 @@ async def upload_file(
         # Verify folder exists if specified
         # Verify folder exists if specified
         if folder_id is not None:
         if folder_id is not None:
             folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
             folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
-            if not folder_result.scalar_one_or_none():
+            target_folder = folder_result.scalar_one_or_none()
+            if not target_folder:
                 raise HTTPException(status_code=404, detail="Folder not found")
                 raise HTTPException(status_code=404, detail="Folder not found")
+            if target_folder.is_external and target_folder.external_readonly:
+                raise HTTPException(status_code=403, detail="Cannot upload to a read-only external folder")
 
 
         # Generate unique filename for storage
         # Generate unique filename for storage
         unique_filename = f"{uuid.uuid4().hex}{ext}"
         unique_filename = f"{uuid.uuid4().hex}{ext}"
@@ -859,8 +1151,11 @@ async def extract_zip_file(
     # Verify target folder exists if specified
     # Verify target folder exists if specified
     if folder_id is not None:
     if folder_id is not None:
         folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
         folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
-        if not folder_result.scalar_one_or_none():
+        target_folder = folder_result.scalar_one_or_none()
+        if not target_folder:
             raise HTTPException(status_code=404, detail="Target folder not found")
             raise HTTPException(status_code=404, detail="Target folder not found")
+        if target_folder.is_external and target_folder.external_readonly:
+            raise HTTPException(status_code=403, detail="Cannot extract ZIP to a read-only external folder")
 
 
     # Save ZIP to temp file
     # Save ZIP to temp file
     try:
     try:
@@ -1994,12 +2289,14 @@ async def delete_file(
         if file.created_by_id != user.id:
         if file.created_by_id != user.id:
             raise HTTPException(status_code=403, detail="You can only delete your own files")
             raise HTTPException(status_code=403, detail="You can only delete your own files")
 
 
-    # Delete actual files
+    # External files: only remove DB entry and thumbnail, never delete the actual file
     try:
     try:
-        abs_file_path = to_absolute_path(file.file_path)
+        if not file.is_external:
+            abs_file_path = to_absolute_path(file.file_path)
+            if abs_file_path and abs_file_path.exists():
+                abs_file_path.unlink()
+        # Always clean up thumbnails we generated
         abs_thumb_path = to_absolute_path(file.thumbnail_path)
         abs_thumb_path = to_absolute_path(file.thumbnail_path)
-        if abs_file_path and abs_file_path.exists():
-            abs_file_path.unlink()
         if abs_thumb_path and abs_thumb_path.exists():
         if abs_thumb_path and abs_thumb_path.exists():
             abs_thumb_path.unlink()
             abs_thumb_path.unlink()
     except OSError as e:
     except OSError as e:
@@ -2180,8 +2477,11 @@ async def move_files(
     # Verify folder exists if specified
     # Verify folder exists if specified
     if data.folder_id is not None:
     if data.folder_id is not None:
         folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
         folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
-        if not folder_result.scalar_one_or_none():
+        target_folder = folder_result.scalar_one_or_none()
+        if not target_folder:
             raise HTTPException(status_code=404, detail="Folder not found")
             raise HTTPException(status_code=404, detail="Folder not found")
+        if target_folder.is_external and target_folder.external_readonly:
+            raise HTTPException(status_code=403, detail="Cannot move files to a read-only external folder")
 
 
     # Update files
     # Update files
     moved = 0
     moved = 0
@@ -2194,6 +2494,10 @@ async def move_files(
             if not can_modify_all and file.created_by_id != user.id:
             if not can_modify_all and file.created_by_id != user.id:
                 skipped += 1
                 skipped += 1
                 continue
                 continue
+            # Cannot move external files out of their folder
+            if file.is_external:
+                skipped += 1
+                continue
             file.folder_id = data.folder_id
             file.folder_id = data.folder_id
             moved += 1
             moved += 1
 
 
@@ -2231,10 +2535,11 @@ async def bulk_delete(
                 continue
                 continue
 
 
             try:
             try:
-                abs_file_path = to_absolute_path(file.file_path)
+                if not file.is_external:
+                    abs_file_path = to_absolute_path(file.file_path)
+                    if abs_file_path and abs_file_path.exists():
+                        abs_file_path.unlink()
                 abs_thumb_path = to_absolute_path(file.thumbnail_path)
                 abs_thumb_path = to_absolute_path(file.thumbnail_path)
-                if abs_file_path and abs_file_path.exists():
-                    abs_file_path.unlink()
                 if abs_thumb_path and abs_thumb_path.exists():
                 if abs_thumb_path and abs_thumb_path.exists():
                     abs_thumb_path.unlink()
                     abs_thumb_path.unlink()
             except OSError as e:
             except OSError as e:

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

@@ -16,6 +16,16 @@ class FolderCreate(BaseModel):
     archive_id: int | None = None
     archive_id: int | None = None
 
 
 
 
+class ExternalFolderCreate(BaseModel):
+    """Schema for linking an external folder."""
+
+    name: str = Field(..., min_length=1, max_length=255)
+    external_path: str = Field(..., min_length=1, max_length=500)
+    readonly: bool = True
+    show_hidden: bool = False
+    parent_id: int | None = None
+
+
 class FolderUpdate(BaseModel):
 class FolderUpdate(BaseModel):
     """Schema for updating a folder."""
     """Schema for updating a folder."""
 
 
@@ -35,6 +45,10 @@ class FolderResponse(BaseModel):
     archive_id: int | None = None
     archive_id: int | None = None
     project_name: str | None = None
     project_name: str | None = None
     archive_name: str | None = None
     archive_name: str | None = None
+    is_external: bool = False
+    external_path: str | None = None
+    external_readonly: bool = False
+    external_show_hidden: bool = False
     file_count: int = 0  # Computed field
     file_count: int = 0  # Computed field
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
@@ -53,6 +67,9 @@ class FolderTreeItem(BaseModel):
     archive_id: int | None = None
     archive_id: int | None = None
     project_name: str | None = None
     project_name: str | None = None
     archive_name: str | None = None
     archive_name: str | None = None
+    is_external: bool = False
+    external_path: str | None = None
+    external_readonly: bool = False
     file_count: int = 0
     file_count: int = 0
     children: list["FolderTreeItem"] = []
     children: list["FolderTreeItem"] = []
 
 
@@ -104,6 +121,7 @@ class FileResponse(BaseModel):
     folder_name: str | None = None
     folder_name: str | None = None
     project_id: int | None
     project_id: int | None
     project_name: str | None = None
     project_name: str | None = None
+    is_external: bool = False
 
 
     filename: str
     filename: str
     file_path: str
     file_path: str
@@ -145,6 +163,7 @@ class FileListResponse(BaseModel):
 
 
     id: int
     id: int
     folder_id: int | None
     folder_id: int | None
+    is_external: bool = False
     filename: str
     filename: str
     file_type: str
     file_type: str
     file_size: int
     file_size: int

+ 378 - 0
backend/tests/integration/test_external_folders_api.py

@@ -0,0 +1,378 @@
+"""Integration tests for External Folder API endpoints."""
+
+import os
+import tempfile
+from pathlib import Path
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestExternalFolderCreation:
+    """Tests for POST /library/folders/external."""
+
+    @pytest.fixture
+    def external_dir(self, tmp_path):
+        """Create a temporary directory to act as an external folder."""
+        ext_dir = tmp_path / "nas_share"
+        ext_dir.mkdir()
+        # Add some test files
+        (ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
+        (ext_dir / "bracket.stl").write_bytes(b"fakestl")
+        (ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
+        (ext_dir / "readme.txt").write_text("not a print file")
+        (ext_dir / ".hidden.3mf").write_bytes(b"hidden")
+        return ext_dir
+
+    @pytest.fixture
+    def nested_external_dir(self, external_dir):
+        """Create a nested subdirectory in the external folder."""
+        sub = external_dir / "subfolder"
+        sub.mkdir()
+        (sub / "nested_part.stl").write_bytes(b"nestedstl")
+        return external_dir
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_external_folder(self, async_client: AsyncClient, db_session, external_dir):
+        """Verify external folder can be created with valid path."""
+        data = {
+            "name": "NAS Prints",
+            "external_path": str(external_dir),
+            "readonly": True,
+            "show_hidden": False,
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "NAS Prints"
+        assert result["is_external"] is True
+        assert result["external_readonly"] is True
+        assert result["external_show_hidden"] is False
+        assert result["external_path"] == str(external_dir.resolve())
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_external_folder_nonexistent_path(self, async_client: AsyncClient, db_session):
+        """Verify 400 for non-existent path."""
+        data = {
+            "name": "Bad Path",
+            "external_path": "/nonexistent/path/that/does/not/exist",
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 400
+        assert "does not exist" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_external_folder_system_dir_blocked(self, async_client: AsyncClient, db_session):
+        """Verify system directories are blocked."""
+        data = {
+            "name": "System",
+            "external_path": "/proc",
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 400
+        assert "system directory" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_external_folder_file_not_dir(self, async_client: AsyncClient, db_session, tmp_path):
+        """Verify 400 when path is a file, not directory."""
+        file_path = tmp_path / "not_a_dir.txt"
+        file_path.write_text("hello")
+        data = {
+            "name": "Not A Dir",
+            "external_path": str(file_path),
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 400
+        assert "not a directory" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_external_folder_duplicate_path(self, async_client: AsyncClient, db_session, external_dir):
+        """Verify 409 when same path already linked."""
+        data = {
+            "name": "First",
+            "external_path": str(external_dir),
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 200
+
+        data["name"] = "Duplicate"
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 409
+        assert "already exists" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_folder_appears_in_tree(self, async_client: AsyncClient, db_session, external_dir):
+        """Verify external folder shows up in folder tree with external fields."""
+        data = {
+            "name": "My NAS",
+            "external_path": str(external_dir),
+            "readonly": True,
+        }
+        await async_client.post("/api/v1/library/folders/external", json=data)
+
+        response = await async_client.get("/api/v1/library/folders")
+        assert response.status_code == 200
+        folders = response.json()
+        ext_folder = next((f for f in folders if f["name"] == "My NAS"), None)
+        assert ext_folder is not None
+        assert ext_folder["is_external"] is True
+        assert ext_folder["external_readonly"] is True
+
+
+class TestExternalFolderScan:
+    """Tests for POST /library/folders/{id}/scan."""
+
+    @pytest.fixture
+    def external_dir(self, tmp_path):
+        """Create a temporary directory with test files."""
+        ext_dir = tmp_path / "prints"
+        ext_dir.mkdir()
+        (ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
+        (ext_dir / "bracket.stl").write_bytes(b"fakestl")
+        (ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
+        (ext_dir / "readme.txt").write_text("not a print file")
+        (ext_dir / ".hidden.3mf").write_bytes(b"hidden")
+        sub = ext_dir / "subfolder"
+        sub.mkdir()
+        (sub / "nested.stl").write_bytes(b"nested")
+        return ext_dir
+
+    @pytest.fixture
+    async def external_folder(self, async_client, db_session, external_dir):
+        """Create an external folder via API."""
+        data = {
+            "name": "Scan Test",
+            "external_path": str(external_dir),
+            "readonly": True,
+            "show_hidden": False,
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        return response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_discovers_files(self, async_client: AsyncClient, db_session, external_folder):
+        """Verify scan discovers supported files."""
+        response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+        assert response.status_code == 200
+        result = response.json()
+        # Should find: benchy.3mf, bracket.stl, print.gcode, subfolder/nested.stl
+        # Should skip: readme.txt (unsupported), .hidden.3mf (hidden)
+        assert result["added"] == 4
+        assert result["removed"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_skips_hidden_files(self, async_client: AsyncClient, db_session, external_folder):
+        """Verify hidden files are skipped by default."""
+        await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+
+        # List files in folder
+        response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
+        assert response.status_code == 200
+        files = response.json()
+        filenames = [f["filename"] for f in files]
+        assert ".hidden.3mf" not in filenames
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_shows_hidden_when_enabled(self, async_client: AsyncClient, db_session, external_dir):
+        """Verify hidden files found when show_hidden=True."""
+        data = {
+            "name": "Show Hidden Test",
+            "external_path": str(external_dir),
+            "show_hidden": True,
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        folder = response.json()
+
+        response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+        result = response.json()
+        # Now should also find .hidden.3mf → 5 total
+        assert result["added"] == 5
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_idempotent(self, async_client: AsyncClient, db_session, external_folder):
+        """Verify scanning twice doesn't duplicate files."""
+        response1 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+        assert response1.json()["added"] == 4
+
+        response2 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+        assert response2.json()["added"] == 0
+        assert response2.json()["removed"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_removes_deleted_files(
+        self, async_client: AsyncClient, db_session, external_folder, external_dir
+    ):
+        """Verify scan removes entries for files no longer on disk."""
+        await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+
+        # Delete a file from disk
+        (external_dir / "bracket.stl").unlink()
+
+        response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+        result = response.json()
+        assert result["removed"] == 1
+        assert result["added"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_non_external_folder_fails(self, async_client: AsyncClient, db_session):
+        """Verify scan fails on regular (non-external) folder."""
+        # Create a regular folder
+        data = {"name": "Regular Folder"}
+        response = await async_client.post("/api/v1/library/folders", json=data)
+        folder = response.json()
+
+        response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+        assert response.status_code == 400
+        assert "not an external" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_files_marked_external(self, async_client: AsyncClient, db_session, external_folder):
+        """Verify scanned files have is_external=True."""
+        await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+
+        response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
+        files = response.json()
+        assert len(files) > 0
+        for f in files:
+            assert f["is_external"] is True
+
+
+class TestExternalFolderProtections:
+    """Tests for read-only protections on external folders."""
+
+    @pytest.fixture
+    def external_dir(self, tmp_path):
+        ext_dir = tmp_path / "readonly_share"
+        ext_dir.mkdir()
+        (ext_dir / "test.stl").write_bytes(b"fakestl")
+        return ext_dir
+
+    @pytest.fixture
+    async def readonly_folder(self, async_client, db_session, external_dir):
+        """Create a read-only external folder with files scanned."""
+        data = {
+            "name": "Read Only",
+            "external_path": str(external_dir),
+            "readonly": True,
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        folder = response.json()
+        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+        return folder
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
+        """Verify uploads to read-only external folders are blocked."""
+        import io
+
+        file_content = io.BytesIO(b"test content")
+        response = await async_client.post(
+            f"/api/v1/library/files?folder_id={readonly_folder['id']}",
+            files={"file": ("test.gcode", file_content, "application/octet-stream")},
+        )
+        assert response.status_code == 403
+        assert "read-only" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_move_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
+        """Verify moving files to read-only external folder is blocked."""
+        from backend.app.models.library import LibraryFile
+
+        # Create a regular file
+        lib_file = LibraryFile(
+            filename="regular.3mf",
+            file_path="/test/regular.3mf",
+            file_size=1024,
+            file_type="3mf",
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        data = {"file_ids": [lib_file.id], "folder_id": readonly_folder["id"]}
+        response = await async_client.post("/api/v1/library/files/move", json=data)
+        assert response.status_code == 403
+        assert "read-only" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_files_cannot_be_moved_out(self, async_client: AsyncClient, db_session, readonly_folder):
+        """Verify external files can't be moved to other folders."""
+        # Get the external file ID
+        response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
+        files = response.json()
+        assert len(files) > 0
+        ext_file_id = files[0]["id"]
+
+        # Try to move to root
+        data = {"file_ids": [ext_file_id], "folder_id": None}
+        response = await async_client.post("/api/v1/library/files/move", json=data)
+        assert response.status_code == 200
+        # File should be skipped, not moved
+        result = response.json()
+        assert result["moved"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_external_file_removes_db_only(
+        self, async_client: AsyncClient, db_session, readonly_folder, external_dir
+    ):
+        """Verify deleting an external file only removes DB entry, not the file on disk."""
+        response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
+        files = response.json()
+        ext_file_id = files[0]["id"]
+        ext_filename = files[0]["filename"]
+
+        # Delete via API
+        response = await async_client.delete(f"/api/v1/library/files/{ext_file_id}")
+        assert response.status_code == 200
+
+        # File should still exist on disk
+        assert (external_dir / ext_filename).exists()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_external_folder_preserves_files(
+        self, async_client: AsyncClient, db_session, readonly_folder, external_dir
+    ):
+        """Verify deleting an external folder doesn't delete files from disk."""
+        response = await async_client.delete(f"/api/v1/library/folders/{readonly_folder['id']}")
+        assert response.status_code == 200
+
+        # Files should still exist on disk
+        assert (external_dir / "test.stl").exists()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_zip_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
+        """Verify ZIP extraction to read-only external folder is blocked."""
+        import io
+        import zipfile
+
+        # Create a minimal zip
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w") as zf:
+            zf.writestr("test.stl", b"fakestl")
+        buf.seek(0)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/extract-zip?folder_id={readonly_folder['id']}",
+            files={"file": ("test.zip", buf, "application/zip")},
+        )
+        assert response.status_code == 403
+        assert "read-only" in response.json()["detail"].lower()

+ 302 - 0
frontend/src/__tests__/pages/FileManagerExternalFolder.test.tsx

@@ -0,0 +1,302 @@
+/**
+ * Tests for External Folder functionality in FileManagerPage.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+// Mock data with external folder
+const mockFoldersWithExternal = [
+  {
+    id: 1,
+    name: 'Regular Folder',
+    parent_id: null,
+    file_count: 3,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: false,
+    external_path: null,
+    external_readonly: false,
+    children: [],
+  },
+  {
+    id: 2,
+    name: 'NAS Prints',
+    parent_id: null,
+    file_count: 5,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: true,
+    external_path: '/mnt/nas/prints',
+    external_readonly: true,
+    children: [],
+  },
+  {
+    id: 3,
+    name: 'USB Drive',
+    parent_id: null,
+    file_count: 2,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: true,
+    external_path: '/mnt/usb',
+    external_readonly: false,
+    children: [],
+  },
+];
+
+const mockFiles = [
+  {
+    id: 1,
+    filename: 'benchy.3mf',
+    file_path: '/mnt/nas/prints/benchy.3mf',
+    file_size: 1048576,
+    file_type: '3mf',
+    folder_id: 2,
+    is_external: true,
+    thumbnail_path: null,
+    print_name: 'Benchy',
+    print_time_seconds: 3600,
+    print_count: 0,
+    duplicate_count: 0,
+    created_at: '2024-01-01T00:00:00Z',
+  },
+];
+
+const mockStats = {
+  total_files: 10,
+  total_folders: 3,
+  total_size_bytes: 104857600,
+  disk_free_bytes: 10737418240,
+  disk_total_bytes: 107374182400,
+};
+
+describe('FileManagerPage - External Folders', () => {
+  beforeEach(() => {
+    localStorage.clear();
+
+    server.use(
+      http.get('/api/v1/library/folders', () => {
+        return HttpResponse.json(mockFoldersWithExternal);
+      }),
+      http.get('/api/v1/library/files', () => {
+        return HttpResponse.json(mockFiles);
+      }),
+      http.get('/api/v1/library/stats', () => {
+        return HttpResponse.json(mockStats);
+      }),
+      http.get('/api/v1/settings/', () => {
+        return HttpResponse.json({
+          check_updates: false,
+          check_printer_firmware: false,
+          library_disk_warning_gb: 5,
+        });
+      }),
+      http.post('/api/v1/library/folders/external', async ({ request }) => {
+        const body = await request.json() as { name: string; external_path: string };
+        return HttpResponse.json({
+          id: 10,
+          name: body.name,
+          parent_id: null,
+          is_external: true,
+          external_path: body.external_path,
+          external_readonly: true,
+          external_show_hidden: false,
+          file_count: 0,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        });
+      }),
+      http.post('/api/v1/library/folders/:id/scan', () => {
+        return HttpResponse.json({ status: 'success', added: 3, removed: 0 });
+      }),
+      http.get('/api/v1/projects/', () => {
+        return HttpResponse.json([]);
+      }),
+      http.get('/api/v1/archives/', () => {
+        return HttpResponse.json([]);
+      }),
+      http.delete('/api/v1/library/folders/:id', () => {
+        return HttpResponse.json({ success: true });
+      }),
+      http.delete('/api/v1/library/files/:id', () => {
+        return HttpResponse.json({ success: true });
+      }),
+      http.post('/api/v1/library/files/move', () => {
+        return HttpResponse.json({ success: true });
+      }),
+    );
+  });
+
+  describe('rendering', () => {
+    it('shows Link External button', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External')).toBeInTheDocument();
+      });
+    });
+
+    it('shows external folder in sidebar', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('NAS Prints')).toBeInTheDocument();
+        expect(screen.getByText('USB Drive')).toBeInTheDocument();
+      });
+    });
+
+    it('shows regular folder alongside external', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Regular Folder')).toBeInTheDocument();
+        expect(screen.getByText('NAS Prints')).toBeInTheDocument();
+      });
+    });
+
+    it('shows read-only indicator for readonly external folders', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        // NAS Prints is readonly, should have a lock icon title
+        const lockIcons = document.querySelectorAll('[title="Read Only"]');
+        expect(lockIcons.length).toBeGreaterThan(0);
+      });
+    });
+  });
+
+  describe('external folder modal', () => {
+    it('opens modal when Link External clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Link External'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External Folder')).toBeInTheDocument();
+      });
+    });
+
+    it('modal has name and path fields', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Link External'));
+
+      await waitFor(() => {
+        expect(screen.getByPlaceholderText('e.g., NAS Prints')).toBeInTheDocument();
+        expect(screen.getByPlaceholderText('/mnt/nas/3d-prints')).toBeInTheDocument();
+      });
+    });
+
+    it('modal has readonly checkbox checked by default', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Link External'));
+
+      await waitFor(() => {
+        const readonlyCheckbox = screen.getByText('Read Only').previousElementSibling as HTMLInputElement;
+        expect(readonlyCheckbox).toBeChecked();
+      });
+    });
+
+    it('modal can be closed', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Link External'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External Folder')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Cancel'));
+
+      await waitFor(() => {
+        expect(screen.queryByText('Link External Folder')).not.toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('external folder info bar', () => {
+    it('shows info bar when external folder selected', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('NAS Prints')).toBeInTheDocument();
+      });
+
+      // Click on NAS Prints folder - there are multiple elements, get the one in the sidebar
+      const folderElements = screen.getAllByText('NAS Prints');
+      await user.click(folderElements[0]);
+
+      await waitFor(() => {
+        expect(screen.getByText('External Folder')).toBeInTheDocument();
+        expect(screen.getByText('/mnt/nas/prints')).toBeInTheDocument();
+      });
+    });
+
+    it('shows scan button for external folders', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('NAS Prints')).toBeInTheDocument();
+      });
+
+      const folderElements = screen.getAllByText('NAS Prints');
+      await user.click(folderElements[0]);
+
+      await waitFor(() => {
+        expect(screen.getByText('Scan')).toBeInTheDocument();
+      });
+    });
+
+    it('does not show info bar for regular folders', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Regular Folder')).toBeInTheDocument();
+      });
+
+      const folderElements = screen.getAllByText('Regular Folder');
+      await user.click(folderElements[0]);
+
+      // External Folder label should NOT appear
+      await waitFor(() => {
+        expect(screen.queryByText('External Folder')).not.toBeInTheDocument();
+      });
+    });
+  });
+});

+ 26 - 0
frontend/src/api/client.ts

@@ -4052,6 +4052,15 @@ export const api = {
     }),
     }),
   deleteLibraryFolder: (id: number) =>
   deleteLibraryFolder: (id: number) =>
     request<{ status: string; message: string }>(`/library/folders/${id}`, { method: 'DELETE' }),
     request<{ status: string; message: string }>(`/library/folders/${id}`, { method: 'DELETE' }),
+  createExternalFolder: (data: ExternalFolderCreate) =>
+    request<LibraryFolder>('/library/folders/external', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  scanExternalFolder: (folderId: number) =>
+    request<{ status: string; added: number; removed: number }>(`/library/folders/${folderId}/scan`, {
+      method: 'POST',
+    }),
   getLibraryFoldersByProject: (projectId: number) =>
   getLibraryFoldersByProject: (projectId: number) =>
     request<LibraryFolder[]>(`/library/folders/by-project/${projectId}`),
     request<LibraryFolder[]>(`/library/folders/by-project/${projectId}`),
   getLibraryFoldersByArchive: (archiveId: number) =>
   getLibraryFoldersByArchive: (archiveId: number) =>
@@ -4436,6 +4445,9 @@ export interface LibraryFolderTree {
   archive_id: number | null;
   archive_id: number | null;
   project_name: string | null;
   project_name: string | null;
   archive_name: string | null;
   archive_name: string | null;
+  is_external: boolean;
+  external_path: string | null;
+  external_readonly: boolean;
   file_count: number;
   file_count: number;
   children: LibraryFolderTree[];
   children: LibraryFolderTree[];
 }
 }
@@ -4448,6 +4460,10 @@ export interface LibraryFolder {
   archive_id: number | null;
   archive_id: number | null;
   project_name: string | null;
   project_name: string | null;
   archive_name: string | null;
   archive_name: string | null;
+  is_external: boolean;
+  external_path: string | null;
+  external_readonly: boolean;
+  external_show_hidden: boolean;
   file_count: number;
   file_count: number;
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
@@ -4460,6 +4476,14 @@ export interface LibraryFolderCreate {
   archive_id?: number | null;
   archive_id?: number | null;
 }
 }
 
 
+export interface ExternalFolderCreate {
+  name: string;
+  external_path: string;
+  readonly?: boolean;
+  show_hidden?: boolean;
+  parent_id?: number | null;
+}
+
 export interface LibraryFolderUpdate {
 export interface LibraryFolderUpdate {
   name?: string;
   name?: string;
   parent_id?: number | null;
   parent_id?: number | null;
@@ -4481,6 +4505,7 @@ export interface LibraryFile {
   folder_name: string | null;
   folder_name: string | null;
   project_id: number | null;
   project_id: number | null;
   project_name: string | null;
   project_name: string | null;
+  is_external: boolean;
   filename: string;
   filename: string;
   file_path: string;
   file_path: string;
   file_type: string;
   file_type: string;
@@ -4508,6 +4533,7 @@ export interface LibraryFile {
 export interface LibraryFileListItem {
 export interface LibraryFileListItem {
   id: number;
   id: number;
   folder_id: number | null;
   folder_id: number | null;
+  is_external: boolean;
   filename: string;
   filename: string;
   file_type: string;
   file_type: string;
   file_size: number;
   file_size: number;

+ 14 - 0
frontend/src/i18n/locales/de.ts

@@ -2564,6 +2564,18 @@ export default {
     noPermissionUpload: 'Sie haben keine Berechtigung, Dateien hochzuladen',
     noPermissionUpload: 'Sie haben keine Berechtigung, Dateien hochzuladen',
     noPermissionMoveFiles: 'Sie haben keine Berechtigung, Dateien zu verschieben',
     noPermissionMoveFiles: 'Sie haben keine Berechtigung, Dateien zu verschieben',
     noPermissionDeleteFiles: 'Sie haben keine Berechtigung, Dateien zu löschen',
     noPermissionDeleteFiles: 'Sie haben keine Berechtigung, Dateien zu löschen',
+    // External folder
+    linkExternal: 'Extern verknüpfen',
+    linkExternalFolder: 'Externen Ordner verknüpfen',
+    linkExternalFolderDescription: 'Ein Host-Verzeichnis (NAS, USB, Netzlaufwerk) in den Dateimanager einbinden. Dateien werden nicht kopiert — sie werden direkt vom Originalpfad gelesen.',
+    externalFolderNamePlaceholder: 'z.B. NAS-Drucke',
+    externalPath: 'Host-Pfad',
+    externalPathHelp: 'Absoluter Pfad zum Verzeichnis auf dem Docker-Host. Muss als Bind-Mount in den Container eingebunden sein.',
+    readOnly: 'Nur Lesen',
+    readOnlyHelp: 'verhindert Uploads und Löschungen',
+    showHiddenFiles: 'Versteckte Dateien anzeigen (Punkt-Dateien)',
+    externalFolder: 'Externer Ordner',
+    scanFolder: 'Scannen',
     toast: {
     toast: {
       folderCreated: 'Ordner erstellt',
       folderCreated: 'Ordner erstellt',
       folderDeleted: 'Ordner gelöscht',
       folderDeleted: 'Ordner gelöscht',
@@ -2572,6 +2584,8 @@ export default {
       filesMoved: 'Dateien verschoben',
       filesMoved: 'Dateien verschoben',
       folderLinked: 'Ordner verknüpft',
       folderLinked: 'Ordner verknüpft',
       folderUnlinked: 'Ordnerverknüpfung aufgehoben',
       folderUnlinked: 'Ordnerverknüpfung aufgehoben',
+      externalFolderLinked: 'Externer Ordner verknüpft und gescannt',
+      folderScanned: 'Scan abgeschlossen: {{added}} hinzugefügt, {{removed}} entfernt',
       addedToQueue: '{{count}} Datei(en) zur Warteschlange hinzugefügt',
       addedToQueue: '{{count}} Datei(en) zur Warteschlange hinzugefügt',
       addedToQueuePartial: '{{added}} Datei(en) hinzugefügt, {{failed}} fehlgeschlagen',
       addedToQueuePartial: '{{added}} Datei(en) hinzugefügt, {{failed}} fehlgeschlagen',
       failedToAddToQueue: 'Fehler beim Hinzufügen: {{error}}',
       failedToAddToQueue: 'Fehler beim Hinzufügen: {{error}}',

+ 14 - 0
frontend/src/i18n/locales/en.ts

@@ -2564,6 +2564,18 @@ export default {
     noPermissionUpload: 'You do not have permission to upload files',
     noPermissionUpload: 'You do not have permission to upload files',
     noPermissionMoveFiles: 'You do not have permission to move files',
     noPermissionMoveFiles: 'You do not have permission to move files',
     noPermissionDeleteFiles: 'You do not have permission to delete files',
     noPermissionDeleteFiles: 'You do not have permission to delete files',
+    // External folder
+    linkExternal: 'Link External',
+    linkExternalFolder: 'Link External Folder',
+    linkExternalFolderDescription: 'Mount a host directory (NAS, USB, network share) into the File Manager. Files are not copied — they are accessed directly from the original path.',
+    externalFolderNamePlaceholder: 'e.g., NAS Prints',
+    externalPath: 'Host Path',
+    externalPathHelp: 'Absolute path to the directory on the Docker host. Must be bind-mounted into the container.',
+    readOnly: 'Read Only',
+    readOnlyHelp: 'prevents uploads and deletions',
+    showHiddenFiles: 'Show hidden files (dotfiles)',
+    externalFolder: 'External Folder',
+    scanFolder: 'Scan',
     toast: {
     toast: {
       folderCreated: 'Folder created',
       folderCreated: 'Folder created',
       folderDeleted: 'Folder deleted',
       folderDeleted: 'Folder deleted',
@@ -2572,6 +2584,8 @@ export default {
       filesMoved: 'Files moved',
       filesMoved: 'Files moved',
       folderLinked: 'Folder linked',
       folderLinked: 'Folder linked',
       folderUnlinked: 'Folder unlinked',
       folderUnlinked: 'Folder unlinked',
+      externalFolderLinked: 'External folder linked and scanned',
+      folderScanned: 'Scan complete: {{added}} added, {{removed}} removed',
       addedToQueue: 'Added {{count}} file(s) to queue',
       addedToQueue: 'Added {{count}} file(s) to queue',
       addedToQueuePartial: 'Added {{added}} file(s), {{failed}} failed',
       addedToQueuePartial: 'Added {{added}} file(s), {{failed}} failed',
       failedToAddToQueue: 'Failed to add files: {{error}}',
       failedToAddToQueue: 'Failed to add files: {{error}}',

+ 14 - 0
frontend/src/i18n/locales/fr.ts

@@ -2551,6 +2551,18 @@ export default {
     noPermissionUpload: 'Pas d\'autorisation téléversement',
     noPermissionUpload: 'Pas d\'autorisation téléversement',
     noPermissionMoveFiles: 'Pas d\'autorisation déplacement',
     noPermissionMoveFiles: 'Pas d\'autorisation déplacement',
     noPermissionDeleteFiles: 'Pas d\'autorisation suppression groupée',
     noPermissionDeleteFiles: 'Pas d\'autorisation suppression groupée',
+    // External folder
+    linkExternal: 'Lier externe',
+    linkExternalFolder: 'Lier un dossier externe',
+    linkExternalFolderDescription: 'Monter un répertoire hôte (NAS, USB, partage réseau) dans le gestionnaire de fichiers. Les fichiers ne sont pas copiés — ils sont lus directement depuis le chemin d\'origine.',
+    externalFolderNamePlaceholder: 'ex. Impressions NAS',
+    externalPath: 'Chemin hôte',
+    externalPathHelp: 'Chemin absolu du répertoire sur l\'hôte Docker. Doit être monté en bind dans le conteneur.',
+    readOnly: 'Lecture seule',
+    readOnlyHelp: 'empêche les téléversements et suppressions',
+    showHiddenFiles: 'Afficher les fichiers cachés (fichiers point)',
+    externalFolder: 'Dossier externe',
+    scanFolder: 'Scanner',
     toast: {
     toast: {
       folderCreated: 'Dossier créé',
       folderCreated: 'Dossier créé',
       folderDeleted: 'Dossier supprimé',
       folderDeleted: 'Dossier supprimé',
@@ -2559,6 +2571,8 @@ export default {
       filesMoved: 'Fichiers déplacés',
       filesMoved: 'Fichiers déplacés',
       folderLinked: 'Dossier lié',
       folderLinked: 'Dossier lié',
       folderUnlinked: 'Dossier délié',
       folderUnlinked: 'Dossier délié',
+      externalFolderLinked: 'Dossier externe lié et scanné',
+      folderScanned: 'Scan terminé : {{added}} ajoutés, {{removed}} supprimés',
       addedToQueue: '{{count}} fichier(s) ajouté(s)',
       addedToQueue: '{{count}} fichier(s) ajouté(s)',
       addedToQueuePartial: '{{added}} ajoutés, {{failed}} échecs',
       addedToQueuePartial: '{{added}} ajoutés, {{failed}} échecs',
       failedToAddToQueue: 'Échec ajout file : {{error}}',
       failedToAddToQueue: 'Échec ajout file : {{error}}',

+ 14 - 0
frontend/src/i18n/locales/it.ts

@@ -2550,6 +2550,18 @@ export default {
     noPermissionUpload: 'Non hai il permesso di caricare file',
     noPermissionUpload: 'Non hai il permesso di caricare file',
     noPermissionMoveFiles: 'Non hai il permesso di spostare file',
     noPermissionMoveFiles: 'Non hai il permesso di spostare file',
     noPermissionDeleteFiles: 'Non hai il permesso di eliminare file',
     noPermissionDeleteFiles: 'Non hai il permesso di eliminare file',
+    // External folder
+    linkExternal: 'Collega esterno',
+    linkExternalFolder: 'Collega cartella esterna',
+    linkExternalFolderDescription: 'Monta una directory host (NAS, USB, condivisione di rete) nel File Manager. I file non vengono copiati — vengono letti direttamente dal percorso originale.',
+    externalFolderNamePlaceholder: 'es. Stampe NAS',
+    externalPath: 'Percorso host',
+    externalPathHelp: 'Percorso assoluto della directory sull\'host Docker. Deve essere montato come bind nel container.',
+    readOnly: 'Sola lettura',
+    readOnlyHelp: 'impedisce caricamenti e cancellazioni',
+    showHiddenFiles: 'Mostra file nascosti (file punto)',
+    externalFolder: 'Cartella esterna',
+    scanFolder: 'Scansiona',
     toast: {
     toast: {
       folderCreated: 'Cartella creata',
       folderCreated: 'Cartella creata',
       folderDeleted: 'Cartella eliminata',
       folderDeleted: 'Cartella eliminata',
@@ -2558,6 +2570,8 @@ export default {
       filesMoved: 'File spostati',
       filesMoved: 'File spostati',
       folderLinked: 'Cartella collegata',
       folderLinked: 'Cartella collegata',
       folderUnlinked: 'Cartella scollegata',
       folderUnlinked: 'Cartella scollegata',
+      externalFolderLinked: 'Cartella esterna collegata e scansionata',
+      folderScanned: 'Scansione completata: {{added}} aggiunti, {{removed}} rimossi',
       addedToQueue: 'Aggiunti {{count}} file alla coda',
       addedToQueue: 'Aggiunti {{count}} file alla coda',
       addedToQueuePartial: 'Aggiunti {{added}} file, {{failed}} falliti',
       addedToQueuePartial: 'Aggiunti {{added}} file, {{failed}} falliti',
       failedToAddToQueue: 'Aggiunta file fallita: {{error}}',
       failedToAddToQueue: 'Aggiunta file fallita: {{error}}',

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

@@ -2563,6 +2563,18 @@ export default {
     noPermissionUpload: 'ファイルをアップロードする権限がありません',
     noPermissionUpload: 'ファイルをアップロードする権限がありません',
     noPermissionMoveFiles: 'ファイルを移動する権限がありません',
     noPermissionMoveFiles: 'ファイルを移動する権限がありません',
     noPermissionDeleteFiles: 'ファイルを削除する権限がありません',
     noPermissionDeleteFiles: 'ファイルを削除する権限がありません',
+    // External folder
+    linkExternal: '外部リンク',
+    linkExternalFolder: '外部フォルダをリンク',
+    linkExternalFolderDescription: 'ホストディレクトリ(NAS、USB、ネットワーク共有)をファイルマネージャにマウントします。ファイルはコピーされず、元のパスから直接アクセスされます。',
+    externalFolderNamePlaceholder: '例:NASプリント',
+    externalPath: 'ホストパス',
+    externalPathHelp: 'Dockerホスト上のディレクトリの絶対パス。コンテナにバインドマウントされている必要があります。',
+    readOnly: '読み取り専用',
+    readOnlyHelp: 'アップロードと削除を防止',
+    showHiddenFiles: '隠しファイルを表示(ドットファイル)',
+    externalFolder: '外部フォルダ',
+    scanFolder: 'スキャン',
     toast: {
     toast: {
       folderCreated: 'フォルダを作成しました',
       folderCreated: 'フォルダを作成しました',
       folderDeleted: 'フォルダを削除しました',
       folderDeleted: 'フォルダを削除しました',
@@ -2571,6 +2583,8 @@ export default {
       filesMoved: 'ファイルを移動しました',
       filesMoved: 'ファイルを移動しました',
       folderLinked: 'フォルダをリンクしました',
       folderLinked: 'フォルダをリンクしました',
       folderUnlinked: 'フォルダのリンクを解除しました',
       folderUnlinked: 'フォルダのリンクを解除しました',
+      externalFolderLinked: '外部フォルダがリンクされスキャンされました',
+      folderScanned: 'スキャン完了:{{added}}件追加、{{removed}}件削除',
       addedToQueue: '{{count}}個のファイルをキューに追加しました',
       addedToQueue: '{{count}}個のファイルをキューに追加しました',
       addedToQueuePartial: '{{added}}件追加、{{failed}}件失敗',
       addedToQueuePartial: '{{added}}件追加、{{failed}}件失敗',
       failedToAddToQueue: 'ファイルの追加に失敗: {{error}}',
       failedToAddToQueue: 'ファイルの追加に失敗: {{error}}',

+ 14 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2550,6 +2550,18 @@ export default {
     noPermissionUpload: 'Você não tem permissão para enviar arquivos',
     noPermissionUpload: 'Você não tem permissão para enviar arquivos',
     noPermissionMoveFiles: 'Você não tem permissão para mover arquivos',
     noPermissionMoveFiles: 'Você não tem permissão para mover arquivos',
     noPermissionDeleteFiles: 'Você não tem permissão para excluir arquivos',
     noPermissionDeleteFiles: 'Você não tem permissão para excluir arquivos',
+    // External folder
+    linkExternal: 'Vincular externo',
+    linkExternalFolder: 'Vincular pasta externa',
+    linkExternalFolderDescription: 'Montar um diretório do host (NAS, USB, compartilhamento de rede) no Gerenciador de Arquivos. Os arquivos não são copiados — são acessados diretamente do caminho original.',
+    externalFolderNamePlaceholder: 'ex. Impressões NAS',
+    externalPath: 'Caminho do host',
+    externalPathHelp: 'Caminho absoluto do diretório no host Docker. Deve estar montado como bind no contêiner.',
+    readOnly: 'Somente leitura',
+    readOnlyHelp: 'impede uploads e exclusões',
+    showHiddenFiles: 'Mostrar arquivos ocultos (arquivos ponto)',
+    externalFolder: 'Pasta externa',
+    scanFolder: 'Escanear',
     toast: {
     toast: {
       folderCreated: 'Pasta criada',
       folderCreated: 'Pasta criada',
       folderDeleted: 'Pasta excluída',
       folderDeleted: 'Pasta excluída',
@@ -2558,6 +2570,8 @@ export default {
       filesMoved: 'Arquivos movidos',
       filesMoved: 'Arquivos movidos',
       folderLinked: 'Pasta vinculada',
       folderLinked: 'Pasta vinculada',
       folderUnlinked: 'Pasta desvinculada',
       folderUnlinked: 'Pasta desvinculada',
+      externalFolderLinked: 'Pasta externa vinculada e escaneada',
+      folderScanned: 'Escaneamento concluído: {{added}} adicionados, {{removed}} removidos',
       addedToQueue: 'Adicionado {{count}} arquivo(s) à fila',
       addedToQueue: 'Adicionado {{count}} arquivo(s) à fila',
       addedToQueuePartial: 'Adicionado {{added}} arquivo(s), {{failed}} falharam',
       addedToQueuePartial: 'Adicionado {{added}} arquivo(s), {{failed}} falharam',
       failedToAddToQueue: 'Falha ao adicionar arquivos: {{error}}',
       failedToAddToQueue: 'Falha ao adicionar arquivos: {{error}}',

+ 14 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2550,6 +2550,18 @@ export default {
     noPermissionUpload: '您没有上传文件的权限',
     noPermissionUpload: '您没有上传文件的权限',
     noPermissionMoveFiles: '您没有移动文件的权限',
     noPermissionMoveFiles: '您没有移动文件的权限',
     noPermissionDeleteFiles: '您没有删除文件的权限',
     noPermissionDeleteFiles: '您没有删除文件的权限',
+    // External folder
+    linkExternal: '链接外部',
+    linkExternalFolder: '链接外部文件夹',
+    linkExternalFolderDescription: '将主机目录(NAS、USB、网络共享)挂载到文件管理器中。文件不会被复制——直接从原始路径访问。',
+    externalFolderNamePlaceholder: '例如:NAS打印文件',
+    externalPath: '主机路径',
+    externalPathHelp: 'Docker主机上目录的绝对路径。必须以绑定挂载方式挂载到容器中。',
+    readOnly: '只读',
+    readOnlyHelp: '防止上传和删除',
+    showHiddenFiles: '显示隐藏文件(点文件)',
+    externalFolder: '外部文件夹',
+    scanFolder: '扫描',
     toast: {
     toast: {
       folderCreated: '文件夹已创建',
       folderCreated: '文件夹已创建',
       folderDeleted: '文件夹已删除',
       folderDeleted: '文件夹已删除',
@@ -2558,6 +2570,8 @@ export default {
       filesMoved: '文件已移动',
       filesMoved: '文件已移动',
       folderLinked: '文件夹已链接',
       folderLinked: '文件夹已链接',
       folderUnlinked: '文件夹已取消链接',
       folderUnlinked: '文件夹已取消链接',
+      externalFolderLinked: '外部文件夹已链接并扫描',
+      folderScanned: '扫描完成:添加 {{added}} 个,移除 {{removed}} 个',
       addedToQueue: '已将 {{count}} 个文件添加到队列',
       addedToQueue: '已将 {{count}} 个文件添加到队列',
       addedToQueuePartial: '已添加 {{added}} 个文件,{{failed}} 个失败',
       addedToQueuePartial: '已添加 {{added}} 个文件,{{failed}} 个失败',
       failedToAddToQueue: '添加文件失败:{{error}}',
       failedToAddToQueue: '添加文件失败:{{error}}',

+ 211 - 2
frontend/src/pages/FileManagerPage.tsx

@@ -37,6 +37,9 @@ import {
   Image,
   Image,
   User,
   User,
   Box,
   Box,
+  RefreshCw,
+  Lock,
+  FolderSymlink,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type {
 import type {
@@ -44,6 +47,7 @@ import type {
   LibraryFileListItem,
   LibraryFileListItem,
   LibraryFolderCreate,
   LibraryFolderCreate,
   LibraryFolderUpdate,
   LibraryFolderUpdate,
+  ExternalFolderCreate,
   AppSettings,
   AppSettings,
   Archive,
   Archive,
   Permission,
   Permission,
@@ -115,6 +119,104 @@ function NewFolderModal({ parentId, onClose, onSave, isLoading, t }: NewFolderMo
   );
   );
 }
 }
 
 
+// External Folder Modal
+interface ExternalFolderModalProps {
+  onClose: () => void;
+  onSave: (data: ExternalFolderCreate) => void;
+  isLoading: boolean;
+  t: TFunction;
+}
+
+function ExternalFolderModal({ onClose, onSave, isLoading, t }: ExternalFolderModalProps) {
+  const [name, setName] = useState('');
+  const [path, setPath] = useState('');
+  const [readonly, setReadonly] = useState(true);
+  const [showHidden, setShowHidden] = useState(false);
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    onSave({
+      name: name.trim(),
+      external_path: path.trim(),
+      readonly,
+      show_hidden: showHidden,
+    });
+  };
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
+        <div className="p-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+            <FolderSymlink className="w-5 h-5 text-bambu-green" />
+            {t('fileManager.linkExternalFolder')}
+          </h2>
+          <p className="text-sm text-bambu-gray mt-1">{t('fileManager.linkExternalFolderDescription')}</p>
+        </div>
+        <form onSubmit={handleSubmit} className="p-4 space-y-4">
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              {t('fileManager.folderName')}
+            </label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+              placeholder={t('fileManager.externalFolderNamePlaceholder')}
+              autoFocus
+              required
+            />
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              {t('fileManager.externalPath')}
+            </label>
+            <input
+              type="text"
+              value={path}
+              onChange={(e) => setPath(e.target.value)}
+              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green font-mono text-sm"
+              placeholder="/mnt/nas/3d-prints"
+              required
+            />
+            <p className="text-xs text-bambu-gray mt-1">{t('fileManager.externalPathHelp')}</p>
+          </div>
+          <div className="space-y-2">
+            <label className="flex items-center gap-2 cursor-pointer">
+              <input
+                type="checkbox"
+                checked={readonly}
+                onChange={(e) => setReadonly(e.target.checked)}
+                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+              />
+              <span className="text-sm text-white">{t('fileManager.readOnly')}</span>
+              <span className="text-xs text-bambu-gray">({t('fileManager.readOnlyHelp')})</span>
+            </label>
+            <label className="flex items-center gap-2 cursor-pointer">
+              <input
+                type="checkbox"
+                checked={showHidden}
+                onChange={(e) => setShowHidden(e.target.checked)}
+                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+              />
+              <span className="text-sm text-white">{t('fileManager.showHiddenFiles')}</span>
+            </label>
+          </div>
+          <div className="flex justify-end gap-2 pt-2">
+            <Button type="button" variant="secondary" onClick={onClose}>
+              {t('common.cancel')}
+            </Button>
+            <Button type="submit" disabled={!name.trim() || !path.trim() || isLoading}>
+              {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('fileManager.linkFolder')}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}
+
 // Rename Modal
 // Rename Modal
 interface RenameModalProps {
 interface RenameModalProps {
   type: 'file' | 'folder';
   type: 'file' | 'folder';
@@ -432,6 +534,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
   const hasChildren = folder.children.length > 0;
   const hasChildren = folder.children.length > 0;
   const isLinked = folder.project_id || folder.archive_id;
   const isLinked = folder.project_id || folder.archive_id;
+  const isExternal = folder.is_external;
 
 
   return (
   return (
     <div>
     <div>
@@ -457,7 +560,11 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
         ) : (
         ) : (
           <div className="w-4.5" />
           <div className="w-4.5" />
         )}
         )}
-        <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
+        {isExternal ? (
+          <FolderSymlink className="w-4 h-4 text-purple-400 flex-shrink-0" />
+        ) : (
+          <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
+        )}
         <span className={`text-sm flex-1 min-w-0 ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
         <span className={`text-sm flex-1 min-w-0 ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
         {/* Link indicator - clickable to change link */}
         {/* Link indicator - clickable to change link */}
         {isLinked && (
         {isLinked && (
@@ -474,11 +581,17 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
             )}
             )}
           </button>
           </button>
         )}
         )}
+        {/* Read-only indicator for external folders */}
+        {isExternal && folder.external_readonly && (
+          <span title={t('fileManager.readOnly')}>
+            <Lock className="w-3 h-3 text-amber-400 flex-shrink-0" />
+          </span>
+        )}
         {folder.file_count > 0 && (
         {folder.file_count > 0 && (
           <span className="flex-shrink-0 text-xs text-bambu-gray">{folder.file_count}</span>
           <span className="flex-shrink-0 text-xs text-bambu-gray">{folder.file_count}</span>
         )}
         )}
         {/* Quick link button - always visible for unlinked folders */}
         {/* Quick link button - always visible for unlinked folders */}
-        {!isLinked && (
+        {!isLinked && !isExternal && (
           <button
           <button
             onClick={(e) => { e.stopPropagation(); onLink(folder); }}
             onClick={(e) => { e.stopPropagation(); onLink(folder); }}
             className="flex-shrink-0 p-1 rounded hover:bg-bambu-dark-tertiary"
             className="flex-shrink-0 p-1 rounded hover:bg-bambu-dark-tertiary"
@@ -785,6 +898,7 @@ export function FileManagerPage() {
   const [selectedFolderId, setSelectedFolderId] = useState<number | null>(initialFolderId);
   const [selectedFolderId, setSelectedFolderId] = useState<number | null>(initialFolderId);
   const [selectedFiles, setSelectedFiles] = useState<number[]>([]);
   const [selectedFiles, setSelectedFiles] = useState<number[]>([]);
   const [showNewFolderModal, setShowNewFolderModal] = useState(false);
   const [showNewFolderModal, setShowNewFolderModal] = useState(false);
+  const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
   const [showMoveModal, setShowMoveModal] = useState(false);
   const [showMoveModal, setShowMoveModal] = useState(false);
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
@@ -979,6 +1093,35 @@ export function FileManagerPage() {
     onError: (error: Error) => showToast(error.message, 'error'),
     onError: (error: Error) => showToast(error.message, 'error'),
   });
   });
 
 
+  const createExternalFolderMutation = useMutation({
+    mutationFn: async (data: ExternalFolderCreate) => {
+      const folder = await api.createExternalFolder(data);
+      // Auto-scan after creation
+      await api.scanExternalFolder(folder.id);
+      return folder;
+    },
+    onSuccess: (folder) => {
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-stats'] });
+      setShowExternalFolderModal(false);
+      setSelectedFolderId(folder.id);
+      showToast(t('fileManager.toast.externalFolderLinked'), 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const scanExternalFolderMutation = useMutation({
+    mutationFn: (folderId: number) => api.scanExternalFolder(folderId),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['library-stats'] });
+      showToast(t('fileManager.toast.folderScanned', { added: result.added, removed: result.removed }), 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
   const deleteFolderMutation = useMutation({
   const deleteFolderMutation = useMutation({
     mutationFn: (id: number) => api.deleteLibraryFolder(id),
     mutationFn: (id: number) => api.deleteLibraryFolder(id),
     onSuccess: () => {
     onSuccess: () => {
@@ -1195,6 +1338,20 @@ export function FileManagerPage() {
 
 
   const isLoading = foldersLoading || filesLoading;
   const isLoading = foldersLoading || filesLoading;
 
 
+  // Find the selected folder in the tree to check external status
+  const selectedFolder = useMemo(() => {
+    if (!selectedFolderId || !folders) return null;
+    const findFolder = (items: LibraryFolderTree[]): LibraryFolderTree | null => {
+      for (const item of items) {
+        if (item.id === selectedFolderId) return item;
+        const found = findFolder(item.children);
+        if (found) return found;
+      }
+      return null;
+    };
+    return findFolder(folders);
+  }, [selectedFolderId, folders]);
+
   return (
   return (
     <div className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col">
     <div className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col">
       {/* Header */}
       {/* Header */}
@@ -1245,6 +1402,15 @@ export function FileManagerPage() {
             )}
             )}
             {t('fileManager.generateThumbnails')}
             {t('fileManager.generateThumbnails')}
           </Button>
           </Button>
+          <Button
+            variant="secondary"
+            onClick={() => setShowExternalFolderModal(true)}
+            disabled={!hasPermission('library:upload')}
+            title={!hasPermission('library:upload') ? t('fileManager.noPermissionCreateFolder') : t('fileManager.linkExternalFolder')}
+          >
+            <FolderSymlink className="w-4 h-4 mr-2" />
+            {t('fileManager.linkExternal')}
+          </Button>
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             onClick={() => setShowNewFolderModal(true)}
             onClick={() => setShowNewFolderModal(true)}
@@ -1416,6 +1582,40 @@ export function FileManagerPage() {
 
 
         {/* Files area */}
         {/* Files area */}
         <div className="flex-1 flex flex-col min-w-0 min-h-0">
         <div className="flex-1 flex flex-col min-w-0 min-h-0">
+          {/* External folder info bar */}
+          {selectedFolder?.is_external && (
+            <div className="flex items-center gap-3 mb-4 p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
+              <FolderSymlink className="w-5 h-5 text-purple-400 flex-shrink-0" />
+              <div className="flex-1 min-w-0">
+                <div className="flex items-center gap-2">
+                  <span className="text-sm font-medium text-purple-300">{t('fileManager.externalFolder')}</span>
+                  {selectedFolder.external_readonly && (
+                    <span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 flex items-center gap-1">
+                      <Lock className="w-3 h-3" />
+                      {t('fileManager.readOnly')}
+                    </span>
+                  )}
+                </div>
+                <p className="text-xs text-bambu-gray truncate font-mono" title={selectedFolder.external_path || ''}>
+                  {selectedFolder.external_path}
+                </p>
+              </div>
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => selectedFolderId && scanExternalFolderMutation.mutate(selectedFolderId)}
+                disabled={scanExternalFolderMutation.isPending}
+                title={t('fileManager.scanFolder')}
+              >
+                {scanExternalFolderMutation.isPending ? (
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                ) : (
+                  <RefreshCw className="w-4 h-4" />
+                )}
+                <span className="ml-1.5">{t('fileManager.scanFolder')}</span>
+              </Button>
+            </div>
+          )}
           {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
           {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
           {files && files.length > 0 && (
           {files && files.length > 0 && (
             <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static">
             <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static">
@@ -1898,6 +2098,15 @@ export function FileManagerPage() {
         />
         />
       )}
       )}
 
 
+      {showExternalFolderModal && (
+        <ExternalFolderModal
+          onClose={() => setShowExternalFolderModal(false)}
+          onSave={(data) => createExternalFolderMutation.mutate(data)}
+          isLoading={createExternalFolderMutation.isPending}
+          t={t}
+        />
+      )}
+
       {showMoveModal && folders && (
       {showMoveModal && folders && (
         <MoveFilesModal
         <MoveFilesModal
           folders={folders}
           folders={folders}

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


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-aq4iRnuK.js"></script>
+    <script type="module" crossorigin src="/assets/index-oZ4Z7wNo.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Dzv7xI7m.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Dzv7xI7m.css">
   </head>
   </head>
   <body>
   <body>

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