diff --git a/.env b/.env index 0951813..9d4f56e 100644 --- a/.env +++ b/.env @@ -9,7 +9,7 @@ # macOS / Linux: PHOTO_DIRS=/Users/you/Pictures # Network share: PHOTO_DIRS=/mnt/nas/photos # Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures -PHOTO_DIRS=./photos +PHOTO_DIRS=/Users/dtoro/Pictures/MulitaTest # Redis configuration REDIS_URL=redis://localhost:6379 diff --git a/README.md b/README.md index 247708c..4866a7f 100644 --- a/README.md +++ b/README.md @@ -70,33 +70,33 @@ docker compose up -d You don't need to touch `mulita.yml` or the API to get started. -### How "mounted folders" and "source folders" relate +### How libraries are managed -There are two layers, and confusing them is the most common source of -"why doesn't this work" questions: +Mulita is **config-driven**: the host directory you mount via +`PHOTO_DIRS` becomes your library, and the backend automatically +registers it as a source root on startup. There is no UI for adding +or removing source roots — to change what Mulita scans, edit `.env` +(or `docker-compose.yml` for multi-mount setups) and restart the +stack. -| Layer | Lives in | What it controls | -|---|---|---| -| **Mount** | `docker-compose.yml` (`${PHOTO_DIRS}:/photos:rw`) | What's *visible* inside the container | -| **Source root** | Database (managed by the UI) | What the scanner *walks and indexes* | +This keeps the model simple: **the docker mount IS the library**. +No two layers, no confusion about which view to use. -Both are required. A folder that isn't mounted is invisible to the -container regardless of what the database says, and a folder that's -mounted but not registered as a source root won't be scanned. +### Changing or adding libraries -In practice, the default flow handles this for you: you mount one host -directory via `PHOTO_DIRS`, and the bootstrap automatically registers it -as a source root. If you want to add a *subfolder* of your library as a -separate source root (so it shows up as its own item in the sidebar), -use the **Add Source Folder** button — the dialog is a directory browser -restricted to what's mounted, so you can only add things the container -can actually see. +To point at a different library: +1. Edit `PHOTO_DIRS` in `.env` +2. `docker compose down` +3. (Optional, for a clean slate) `docker volume rm muleimage_db_data muleimage_thumbs_data muleimage_proxies_data` +4. `docker compose up -d` -### Adding more libraries +The new library shows up automatically. Without step 3 the old +library's metadata stays in the DB and you'll see a warning at +startup that the old source root's path is missing on disk — +that's a hint to clean up. -Today the compose file mounts a single host directory as `/photos`. -If you want multiple libraries from different host paths, edit -`docker-compose.yml` and add additional mount lines, e.g.: +For multiple libraries, edit `docker-compose.yml` and add additional +mount lines: ```yaml volumes: @@ -104,8 +104,9 @@ volumes: - /Volumes/Archive:/archive:rw # additional library ``` -Then in the UI, click **Add Source Folder**, browse to `/archive`, and -add it. (Multi-mount support via env vars is on the roadmap.) +Each mounted directory will need a corresponding source root row in +the DB; today that means `POST /api/v1/folders` via curl, or wait +for the multi-mount auto-registration that's on the roadmap. ### Read-only libraries diff --git a/backend/app/routers/folders.py b/backend/app/routers/folders.py index 2341df1..607148a 100644 --- a/backend/app/routers/folders.py +++ b/backend/app/routers/folders.py @@ -1,30 +1,19 @@ """ -Folders API router +Folders API router. Source roots are config-driven (PHOTO_DIRS in .env → +backend bootstrap on startup); this router only exposes read access and a +manual rescan trigger. Adding/removing source roots happens by editing +docker-compose.yml + .env and restarting the stack. """ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from typing import List, Optional -from pydantic import BaseModel import os -import uuid from app.database import get_db from app.models import Folder, SourceRoot router = APIRouter() -class FolderCreate(BaseModel): - path: str - recursive: bool = True - watch: bool = False - -class FolderResponse(BaseModel): - id: str - name: str - path: str - photo_count: int - @router.get("") async def get_folders(db: AsyncSession = Depends(get_db)): """Get all source folders""" @@ -50,39 +39,6 @@ async def get_folders(db: AsyncSession = Depends(get_db)): return {"folders": folders_list} -@router.post("") -async def create_folder(folder: FolderCreate, db: AsyncSession = Depends(get_db)): - """Add a new source folder""" - # Check if path exists - if not os.path.exists(folder.path): - raise HTTPException(status_code=400, detail=f"Path does not exist: {folder.path}") - - # Check if path is already added - result = await db.execute(select(SourceRoot).where(SourceRoot.path == folder.path)) - existing = result.scalar_one_or_none() - if existing: - raise HTTPException(status_code=400, detail="Path already added as source folder") - - # Create source root - source_root = SourceRoot( - id=str(uuid.uuid4()), - name=os.path.basename(folder.path), - path=folder.path - ) - db.add(source_root) - await db.commit() - - # Automatically trigger a scan for the new folder - from app.tasks.celery import celery_app - celery_app.send_task('scan_folder', args=[source_root.path, source_root.id]) - - return { - "id": source_root.id, - "name": source_root.name, - "path": source_root.path, - "photo_count": 0 - } - @router.post("/{folder_id}/scan") async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)): """Trigger manual re-scan of source root folder""" diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py index 28b2da9..887b4fe 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -1,21 +1,15 @@ """ -Library API router for stats, scanning, and directory browsing +Library API router for stats and scanning """ -import os -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.models import Photo, SourceRoot +from app.models import Photo router = APIRouter() - -# Always-allowed root for the directory browser. Whatever the user mounts -# as PHOTO_DIRS in .env shows up here. -DEFAULT_LIBRARY_ROOT = "/photos" - @router.get("/stats") async def get_library_stats(db: AsyncSession = Depends(get_db)): """Get library statistics""" @@ -44,89 +38,6 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)): "total_size_gb": round(size / (1024**3), 2) if size else 0 } -@router.get("/browse") -async def browse_directory( - path: str = DEFAULT_LIBRARY_ROOT, - db: AsyncSession = Depends(get_db), -): - """List the immediate child directories of `path` so the frontend can - render a folder picker. The path is validated to live under one of the - allowed roots so this can't be used to enumerate the container - filesystem: - - - The default library mount (/photos) - - Any active SourceRoot the user has already added (and its subtree) - - Returns: - { - "path": str, # canonical (normalized) path - "parent": str | null, # parent path if still inside an allowed root - "is_existing_root": bool # whether `path` is itself a SourceRoot - "children": [ - { "name", "path", "is_existing_root" }, ... - ] - } - """ - # Build the allowed-roots set: default mount + every active SourceRoot. - sr_result = await db.execute( - select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712 - ) - source_roots = sr_result.scalars().all() - sr_paths = [os.path.normpath(sr.path) for sr in source_roots] - allowed_roots = {os.path.normpath(DEFAULT_LIBRARY_ROOT), *sr_paths} - - canonical = os.path.normpath(path) - - # Path must live under (or be) one of the allowed roots — prevents - # browsing /etc, /data/db, etc. - def under_allowed(p: str) -> bool: - for root in allowed_roots: - if p == root or p.startswith(root + os.sep): - return True - return False - - if not under_allowed(canonical): - raise HTTPException( - status_code=403, - detail=f"Path is outside the allowed photo roots", - ) - - if not os.path.isdir(canonical): - raise HTTPException(status_code=404, detail=f"Not a directory: {canonical}") - - # Build the child list — only directories, hidden entries (dotfiles) - # excluded. - sr_path_set = set(sr_paths) - try: - entries = sorted(os.listdir(canonical)) - except OSError as e: - raise HTTPException(status_code=500, detail=f"Cannot read directory: {e}") - - children = [] - for entry in entries: - if entry.startswith('.'): - continue - child_path = os.path.join(canonical, entry) - if not os.path.isdir(child_path): - continue - children.append({ - "name": entry, - "path": child_path, - "is_existing_root": child_path in sr_path_set, - }) - - # Compute parent path if it's still inside an allowed root. - parent = os.path.normpath(os.path.dirname(canonical)) - parent_in_scope = parent != canonical and under_allowed(parent) - - return { - "path": canonical, - "parent": parent if parent_in_scope else None, - "is_existing_root": canonical in sr_path_set, - "children": children, - } - - @router.post("/scan") async def trigger_scan(): """Trigger full library re-scan""" diff --git a/backend/app/services/cleanup.py b/backend/app/services/cleanup.py index d5261d5..dd64a98 100644 --- a/backend/app/services/cleanup.py +++ b/backend/app/services/cleanup.py @@ -119,6 +119,26 @@ async def _recompute_folder_counts(session: AsyncSession) -> None: f.photo_count = int(count_result.scalar() or 0) +async def _warn_stale_source_roots(session: AsyncSession) -> int: + """Log a warning for any active source root whose path no longer exists + on disk. Doesn't delete — a missing path could be a temporarily + unmounted drive, and silently dropping user data is worse than + surfacing a noisy log line. + """ + result = await session.execute(select(SourceRoot)) + rows = result.scalars().all() + stale = 0 + for sr in rows: + if not os.path.isdir(sr.path): + stale += 1 + logger.warning( + f"Source root '{sr.name}' path is missing on disk: {sr.path} " + f"— is the docker mount still in place? " + f"(Edit docker-compose.yml or PHOTO_DIRS in .env to fix.)" + ) + return stale + + async def cleanup_data_integrity() -> dict: """Top-level entry point. Runs the dedupe + count refresh in a single transaction. Returns a small summary dict for logging.""" @@ -127,10 +147,12 @@ async def cleanup_data_integrity() -> dict: sr_deleted = await _dedupe_source_roots(session) f_deleted = await _dedupe_folders(session) await _recompute_folder_counts(session) + stale = await _warn_stale_source_roots(session) await session.commit() summary = { "source_roots_merged": sr_deleted, "folders_merged": f_deleted, + "source_roots_stale": stale, } if sr_deleted or f_deleted: logger.info(f"Cleanup merged duplicates: {summary}") diff --git a/frontend/src/components/dialogs/AddSourceFolderDialog.tsx b/frontend/src/components/dialogs/AddSourceFolderDialog.tsx deleted file mode 100644 index a2682d0..0000000 --- a/frontend/src/components/dialogs/AddSourceFolderDialog.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { useState, useEffect } from 'react' -import { - X, - FolderPlus, - AlertCircle, - ChevronUp, - Folder, - Check, -} from 'lucide-react' -import clsx from 'clsx' -import { library, type BrowseResponse } from '../../services/api' - -interface AddSourceFolderDialogProps { - isOpen: boolean - onClose: () => void - onAdd: (path: string, recursive: boolean) => Promise -} - -/** - * Directory-browser dialog for adding a source folder. - * - * Users can't reasonably know which container path corresponds to their - * host directory, so the dialog instead lets them click through the mounted - * library tree (rooted at /photos by default). The list comes from - * GET /library/browse, which is server-side restricted to allowed roots. - * - * "Add this folder" registers whatever directory is currently shown. - */ -export function AddSourceFolderDialog({ - isOpen, - onClose, - onAdd, -}: AddSourceFolderDialogProps) { - const [browse, setBrowse] = useState(null) - const [loading, setLoading] = useState(false) - const [adding, setAdding] = useState(false) - const [error, setError] = useState(null) - const [recursive, setRecursive] = useState(true) - - // Load the root directory (/photos) on first open. Subsequent navigation - // (clicking a child or the parent button) calls loadPath directly. - useEffect(() => { - if (!isOpen) return - loadPath(undefined) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen]) - - const loadPath = async (path?: string) => { - setLoading(true) - setError(null) - try { - const data = await library.browse(path) - setBrowse(data) - } catch (e: any) { - setError( - e?.response?.data?.detail || - e?.message || - 'Failed to load directory' - ) - } finally { - setLoading(false) - } - } - - const handleAdd = async () => { - if (!browse) return - setAdding(true) - setError(null) - try { - await onAdd(browse.path, recursive) - onClose() - } catch (e: any) { - setError( - e?.response?.data?.detail || - e?.message || - 'Failed to add source folder' - ) - } finally { - setAdding(false) - } - } - - const handleClose = () => { - if (adding) return - setBrowse(null) - setError(null) - onClose() - } - - if (!isOpen) return null - - return ( -
-
- -
- {/* Header */} -
-
- -

- Add source folder -

-
- -
- - {/* Current path + parent nav */} -
- - - {browse?.path ?? '…'} - -
- - {/* Children list */} -
- {loading && ( -
- Loading… -
- )} - {!loading && browse && browse.children.length === 0 && ( -
- No subfolders here -
- )} - {!loading && - browse && - browse.children.map((child) => ( - - ))} -
- - {/* Recursive toggle */} -
- setRecursive(e.target.checked)} - disabled={adding} - className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0" - /> - -
- - {/* Error */} - {error && ( -
- - {error} -
- )} - - {/* Actions */} -
-

- {browse?.is_existing_root - ? 'This folder is already a source root.' - : 'Adds the folder shown above as a source root.'} -

- - -
-
-
- ) -} diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 0ff13b1..a4ea1c7 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -6,13 +6,11 @@ import { Image, Star, Trash2, - Plus, MoreHorizontal, HardDrive, RefreshCw, } from 'lucide-react' import clsx from 'clsx' -import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog' import { sourceFolders, library, photos as photosApi } from '../../services/api' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from '../ToastContainer' @@ -32,7 +30,6 @@ interface TreeItem { export function LeftSidebar() { const [expandedItems, setExpandedItems] = useState>(new Set(['library', 'folders', 'heaps'])) const [selectedItem, setSelectedItem] = useState('all-photos') - const [showAddFolderDialog, setShowAddFolderDialog] = useState(false) const [isScanning, setIsScanning] = useState(false) const queryClient = useQueryClient() @@ -119,32 +116,11 @@ export function LeftSidebar() { } // Fetch folders from API - const { data: foldersData, refetch: refetchFolders } = useQuery({ + const { data: foldersData } = useQuery({ queryKey: ['folders'], queryFn: sourceFolders.list, }) - - // Mutation for adding folders - const addFolderMutation = useMutation({ - mutationFn: async ({ path, recursive }: { path: string; recursive: boolean }) => { - // Add the folder - const folder = await sourceFolders.add(path, recursive) - // Trigger scan for the new folder - await sourceFolders.scan(folder.id) - return folder - }, - onSuccess: (folder) => { - toast.success('Folder Added', `Scanning ${folder.name || folder.path}...`) - // Refetch folders list - refetchFolders() - // Refetch photos to show new ones - queryClient.invalidateQueries({ queryKey: ['photos'] }) - }, - onError: (error: any) => { - toast.error('Failed to Add Folder', error.message || 'An error occurred') - }, - }) - + // Mutation for scanning all folders const scanLibraryMutation = useMutation({ mutationFn: library.scan, @@ -165,10 +141,6 @@ export function LeftSidebar() { }, }) - const handleAddFolder = async (path: string, recursive: boolean) => { - await addFolderMutation.mutateAsync({ path, recursive }) - } - const handleScanAll = () => { scanLibraryMutation.mutate() } @@ -320,18 +292,6 @@ export function LeftSidebar() { )} - {/* Actions (shown on hover) */} - {(item.id === 'folders' || item.id === 'heaps') && ( - - )}
{/* Render Children */} @@ -361,32 +321,18 @@ export function LeftSidebar() { {/* Bottom Actions */} -
- - {foldersData?.folders?.length > 0 && ( - - )} -
- - {/* Add Source Folder Dialog */} - setShowAddFolderDialog(false)} - onAdd={handleAddFolder} - /> + + )} ) } \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f825e21..d48a9c8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -9,31 +9,18 @@ const api = axios.create({ }, }) -// Source Folders API +// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in +// .env → bootstrap on backend startup), so the UI only reads them. export const sourceFolders = { list: async () => { const response = await api.get('/folders') return response.data }, - add: async (path: string, recursive: boolean = true) => { - const response = await api.post('/folders', { - path, - recursive, - watch: false, // Can be made configurable later - }) - return response.data - }, - scan: async (folderId: string) => { const response = await api.post(`/folders/${folderId}/scan`) return response.data }, - - delete: async (folderId: string) => { - const response = await api.delete(`/folders/${folderId}`) - return response.data - }, } // Photos API @@ -113,18 +100,6 @@ export const photos = { } // Library API -export interface BrowseChild { - name: string - path: string - is_existing_root: boolean -} -export interface BrowseResponse { - path: string - parent: string | null - is_existing_root: boolean - children: BrowseChild[] -} - export const library = { scan: async () => { const response = await api.post('/library/scan') @@ -140,15 +115,6 @@ export const library = { const response = await api.get('/library/stats') return response.data }, - - /** List immediate child directories of `path` for the source-folder - * picker. Defaults to the library root (/photos). */ - browse: async (path?: string): Promise => { - const response = await api.get('/library/browse', { - params: path ? { path } : undefined, - }) - return response.data - }, } // Heaps API