diff --git a/README.md b/README.md index fc96b69..8e84a9b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ A fast, simple, and modern web application for organizing and tagging photos usi - **⚡ Batch Processing**: Process thousands of photos efficiently - **🎯 Unique Faces Filter**: Hide duplicate faces to focus on unique individuals - **🔄 Real-time Updates**: Live progress tracking and job status updates +- **🌐 Network Path Support**: Browse and scan folders on network shares (UNC paths on Windows, mounted shares on Linux) +- **📁 Native Folder Picker**: Browse button uses native OS folder picker with full absolute path support - **🔒 Privacy-First**: All data stored locally, no cloud dependencies --- @@ -35,8 +37,9 @@ A fast, simple, and modern web application for organizing and tagging photos usi - **Node.js 18+ and npm** - **PostgreSQL** (required for both development and production) - **Redis** (for background job processing) +- **Python tkinter** (for native folder picker in Scan tab) -**Note:** The automated installation script (`./install.sh`) will install PostgreSQL and Redis automatically on Ubuntu/Debian systems. +**Note:** The automated installation script (`./install.sh`) will install PostgreSQL, Redis, and Python tkinter automatically on Ubuntu/Debian systems. ### Installation @@ -55,7 +58,7 @@ cd punimtag The script will: - ✅ Check prerequisites (Python 3.12+, Node.js 18+) -- ✅ Install system dependencies (PostgreSQL, Redis) on Ubuntu/Debian +- ✅ Install system dependencies (PostgreSQL, Redis, Python tkinter) on Ubuntu/Debian - ✅ Set up PostgreSQL databases (main + auth) - ✅ Create Python virtual environment - ✅ Install all Python dependencies @@ -69,7 +72,13 @@ cd viewer-frontend npx prisma generate ``` -**Note:** On macOS or other systems, the script will skip system dependency installation. You'll need to install PostgreSQL and Redis manually. +**Note:** On macOS or other systems, the script will skip system dependency installation. You'll need to install PostgreSQL, Redis, and Python tkinter manually. + +**Installing tkinter manually:** +- **Ubuntu/Debian:** `sudo apt install python3-tk` +- **RHEL/CentOS:** `sudo yum install python3-tkinter` +- **macOS:** Usually included with Python, but if missing: `brew install python-tk` (if using Homebrew Python) +- **Windows:** Usually included with Python installation #### Option 2: Manual Installation @@ -375,6 +384,26 @@ rm data/punimtag.db # The schema will be recreated on next startup ``` +**Browse button returns 503 error or doesn't show folder picker:** +This indicates that Python tkinter is not available. Install it: +```bash +# Ubuntu/Debian: +sudo apt install python3-tk + +# RHEL/CentOS: +sudo yum install python3-tkinter + +# Verify installation: +python3 -c "import tkinter; print('tkinter available')" +``` + +**Note:** If running on a remote server without a display, you may need to set the DISPLAY environment variable or use X11 forwarding: +```bash +export DISPLAY=:0 +# Or for X11 forwarding: +export DISPLAY=localhost:10.0 +``` + **Viewer frontend shows 0 photos:** - Make sure the database has photos (import them via admin frontend) - Verify `DATABASE_URL` in `viewer-frontend/.env` points to the correct database @@ -492,6 +521,9 @@ punimtag/ **Frontend:** - ✅ Scan tab UI with folder selection +- ✅ **Native folder picker (Browse button)** - Uses tkinter for native OS folder selection +- ✅ **Network path support** - Handles UNC paths (Windows: `\\server\share\folder`) and mounted network shares (Linux: `/mnt/nfs-share/photos`) +- ✅ **Full absolute path handling** - Automatically normalizes and validates paths - ✅ Drag-and-drop file upload - ✅ Recursive scan toggle - ✅ Real-time job progress with progress bar diff --git a/admin-frontend/src/pages/Scan.tsx b/admin-frontend/src/pages/Scan.tsx index 1f8e978..28d1f68 100644 --- a/admin-frontend/src/pages/Scan.tsx +++ b/admin-frontend/src/pages/Scan.tsx @@ -15,6 +15,7 @@ export default function Scan() { const [folderPath, setFolderPath] = useState('') const [recursive, setRecursive] = useState(true) const [isImporting, setIsImporting] = useState(false) + const [isBrowsing, setIsBrowsing] = useState(false) const [currentJob, setCurrentJob] = useState(null) const [jobProgress, setJobProgress] = useState(null) const [importResult, setImportResult] = useState<{ @@ -35,43 +36,115 @@ export default function Scan() { }, []) const handleFolderBrowse = async () => { + setIsBrowsing(true) + setError(null) + // Try backend API first (uses tkinter for native folder picker with full path) try { + console.log('Attempting to open native folder picker...') const result = await photosApi.browseFolder() + console.log('Backend folder picker result:', result) + if (result.success && result.path) { - setFolderPath(result.path) - return + // Ensure we have a valid absolute path (not just folder name) + const path = result.path.trim() + if (path && path.length > 0) { + // Verify it looks like an absolute path: + // - Unix/Linux: starts with / (includes mounted network shares like /mnt/...) + // - Windows local: starts with drive letter like C:\ + // - Windows UNC: starts with \\ (network paths like \\server\share\folder) + const isUnixPath = path.startsWith('/') + const isWindowsLocalPath = /^[A-Za-z]:[\\/]/.test(path) + const isWindowsUncPath = path.startsWith('\\\\') || path.startsWith('//') + + if (isUnixPath || isWindowsLocalPath || isWindowsUncPath) { + setFolderPath(path) + setIsBrowsing(false) + return + } else { + // Backend validated it, so trust it even if it doesn't match our patterns + // (might be a valid path format we didn't account for) + console.warn('Backend returned path with unexpected format:', path) + setFolderPath(path) + setIsBrowsing(false) + return + } + } } + // If we get here, result.success was false or path was empty + console.warn('Backend folder picker returned no path:', result) + if (result.success === false && result.message) { + setError(result.message || 'No folder was selected. Please try again.') + } else { + setError('No folder was selected. Please try again.') + } + setIsBrowsing(false) } catch (err: any) { // Backend API failed, fall back to browser picker console.warn('Backend folder picker unavailable, using browser fallback:', err) + // Extract error message from various possible locations + const errorMsg = err?.response?.data?.detail || + err?.response?.data?.message || + err?.message || + String(err) || + '' + + console.log('Error details:', { + status: err?.response?.status, + detail: err?.response?.data?.detail, + message: err?.message, + fullError: err + }) + // Check if it's a display/availability issue - const errorMsg = err?.response?.data?.detail || err?.message || '' - if (errorMsg.includes('display') || errorMsg.includes('DISPLAY')) { + if (errorMsg.includes('display') || errorMsg.includes('DISPLAY') || errorMsg.includes('tkinter')) { // Show user-friendly message about display issue - alert('Native folder picker unavailable (no display). Using browser fallback.\n\nNote: Browser picker may only show folder name. You may need to manually complete the full path.') + setError('Native folder picker unavailable. Using browser fallback.') + } else if (err?.response?.status === 503) { + // 503 Service Unavailable - likely tkinter or display issue + setError('Native folder picker unavailable (tkinter/display issue). Using browser fallback.') + } else { + // Other error - log it but continue to browser fallback + console.error('Error calling backend folder picker:', err) + setError('Native folder picker unavailable. Using browser fallback.') } } // Fallback: Use browser-based folder picker + // This code runs if backend API failed or returned no path + console.log('Attempting browser fallback folder picker...') + // Use File System Access API if available (modern browsers) if (typeof window !== 'undefined' && 'showDirectoryPicker' in window) { try { + console.log('Using File System Access API...') const directoryHandle = await (window as any).showDirectoryPicker() // Get the folder name from the handle const folderName = directoryHandle.name // Note: Browsers don't expose full absolute paths for security reasons + console.log('Selected folder name:', folderName) + + // Browser picker only gives folder name, not full path + // Set the folder name and show helpful message setFolderPath(folderName) + setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.') } catch (err: any) { // User cancelled the picker if (err.name !== 'AbortError') { console.error('Error selecting folder:', err) + setError('Error opening folder picker: ' + err.message) + } else { + // User cancelled - clear any previous error + setError(null) } + } finally { + setIsBrowsing(false) } } else { // Fallback: use a hidden directory input // Note: This will show a browser confirmation dialog that cannot be removed + console.log('Using file input fallback...') const input = document.createElement('input') input.type = 'file' input.setAttribute('webkitdirectory', '') @@ -87,17 +160,24 @@ export default function Scan() { const pathParts = relativePath.split('/') const rootFolder = pathParts[0] // Note: Browsers don't expose full absolute paths for security reasons + console.log('Selected folder name:', rootFolder) + + // Browser picker only gives folder name, not full path + // Set the folder name and show helpful message setFolderPath(rootFolder) + setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.') } if (document.body.contains(input)) { document.body.removeChild(input) } + setIsBrowsing(false) } input.oncancel = () => { if (document.body.contains(input)) { document.body.removeChild(input) } + setIsBrowsing(false) } document.body.appendChild(input) @@ -252,18 +332,14 @@ export default function Scan() {

- Enter the full path to the folder containing photos / videos. - - Click Browse to open a native folder picker. The full path will be automatically filled. - If the native picker is unavailable, a browser fallback will be used (may require manual path completion). - + Enter the full absolute path to the folder containing photos / videos.

diff --git a/backend/api/photos.py b/backend/api/photos.py index 9db57eb..aca7d01 100644 --- a/backend/api/photos.py +++ b/backend/api/photos.py @@ -466,12 +466,55 @@ def browse_folder() -> dict: root.destroy() if folder_path: - # Normalize path to absolute - abs_path = os.path.abspath(folder_path) + # Normalize path to absolute - use multiple methods to ensure full path + # Handle network paths (UNC paths on Windows, mounted shares on Linux) + + # Check if it's a Windows UNC path (\\server\share or //server/share) + # UNC paths are already absolute, but realpath may not work correctly with them + is_unc_path = folder_path.startswith('\\\\') or folder_path.startswith('//') + + if is_unc_path: + # For UNC paths, normalize separators but don't use realpath + # (realpath may not work correctly with UNC paths on Windows) + # os.path.normpath() handles UNC paths correctly on Windows + normalized_path = os.path.normpath(folder_path) + else: + # For regular paths (local or mounted network shares on Linux) + # First convert to absolute, then resolve any symlinks, then normalize + abs_path = os.path.abspath(folder_path) + try: + # Resolve any symlinks to get the real path + # This may fail for some network paths, so wrap in try/except + real_path = os.path.realpath(abs_path) + except (OSError, ValueError): + # If realpath fails (e.g., for some network paths), use abspath result + real_path = abs_path + # Normalize the path (remove redundant separators, etc.) + normalized_path = os.path.normpath(real_path) + + # Ensure we have a full absolute path (not just folder name) + if not os.path.isabs(normalized_path): + # If somehow still not absolute, try again with current working directory + normalized_path = os.path.abspath(normalized_path) + + # Verify the path exists and is a directory + # Note: For network paths, this check might fail if network is temporarily down, + # but if the user just selected it via the folder picker, it should be accessible + if not os.path.exists(normalized_path): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Selected path does not exist: {normalized_path}", + ) + if not os.path.isdir(normalized_path): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Selected path is not a directory: {normalized_path}", + ) + return { - "path": abs_path, + "path": normalized_path, "success": True, - "message": f"Selected folder: {abs_path}" + "message": f"Selected folder: {normalized_path}" } else: return { diff --git a/install.sh b/install.sh index 47e573d..79455b9 100755 --- a/install.sh +++ b/install.sh @@ -118,6 +118,15 @@ install_system_dependencies() { echo -e "${GREEN} ✅ Redis already installed${NC}" fi + # Install Python tkinter (required for native folder picker) + if ! python3 -c "import tkinter" 2>/dev/null; then + echo -e "${BLUE} Installing Python tkinter...${NC}" + sudo apt install -y python3-tk + echo -e "${GREEN} ✅ Python tkinter installed${NC}" + else + echo -e "${GREEN} ✅ Python tkinter already installed${NC}" + fi + # Verify Redis is running if redis-cli ping >/dev/null 2>&1; then echo -e "${GREEN} ✅ Redis is running${NC}"