diff --git a/.env b/.env index 1d8a719..0951813 100644 --- a/.env +++ b/.env @@ -1,7 +1,14 @@ # Environment variables for Mulita - -# Photo directories to mount (can be multiple paths separated by colon) -# Example: /path/to/photos1:/path/to/photos2 +# +# Set PHOTO_DIRS to the HOST path of your photo library. The compose file +# mounts this at /photos inside the container, and on first boot Mulita +# auto-creates a source root pointing at /photos so your library is +# scanned with zero further configuration. +# +# Examples: +# 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 # Redis configuration diff --git a/README.md b/README.md index b0a3230..247708c 100644 --- a/README.md +++ b/README.md @@ -34,53 +34,96 @@ A self-hosted, Docker-deployed photo management application inspired by Lightroo ### Prerequisites - Docker and Docker Compose -- Photo directories to mount -### Setup +### Setup (one variable) -1. Clone the repository: +1. Clone the repo: ```bash git clone cd muleimage ``` -2. Configure your photo directories in `.env`: +2. Set **one** environment variable in `.env` — the **host** directory + that contains your photo library. Whatever you point at will become + your library inside Mulita. + + ```bash + # macOS / Linux + PHOTO_DIRS=/Users/you/Pictures + + # or any folder + PHOTO_DIRS=/mnt/nas/photos + + # Windows (WSL) + PHOTO_DIRS=/mnt/c/Users/you/Pictures + ``` + +3. Start the stack: ```bash -# Edit .env file -PHOTO_DIRS=/path/to/your/photos +docker compose up -d ``` -3. Start the application: -```bash -docker-compose up -d +4. Open `http://localhost:3000`. On first boot Mulita will: + - Mount your `PHOTO_DIRS` at `/photos` inside the container + - Auto-create a source root called **Library** pointing at `/photos` + - Queue an initial scan, generate thumbnails, and start serving them + +You don't need to touch `mulita.yml` or the API to get started. + +### How "mounted folders" and "source folders" relate + +There are two layers, and confusing them is the most common source of +"why doesn't this work" questions: + +| 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* | + +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. + +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. + +### Adding more libraries + +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.: + +```yaml +volumes: + - ${PHOTO_DIRS}:/photos:rw + - /Volumes/Archive:/archive:rw # additional library ``` -4. Access the application at `http://localhost:3000` +Then in the UI, click **Add Source Folder**, browse to `/archive`, and +add it. (Multi-mount support via env vars is on the roadmap.) -### Photo directory mounts and permissions +### Read-only libraries -Mulita is a Lightroom-style manager — file operations (rename, move, -discard, empty discard pile) need to mutate the filesystem under your -photo mounts. The default `docker-compose.yml` mounts: - -- `${PHOTO_DIRS}` → `/photos` (read-write) -- `~/Pictures` → `/host/Pictures` (**read-write** by default so file - operations work on your system Pictures folder out of the box) - -If you want a strict read-only library — for example pointing at a -network share or your authoritative archive — change `:rw` to `:ro` -on the mount in `docker-compose.yml`. Mulita will keep working for -browsing, rating, color labels, picks, heaps, and the discard flag, -but the following endpoints will return an error from the OS -(`EROFS` / `Read-only file system`): +The default mount is `:rw` because file operations (rename, move, +empty discard pile) need to mutate the filesystem. If you want a +strict read-only library — pointing at a network share, an +authoritative archive, etc. — flip `:rw` to `:ro` in +`docker-compose.yml`. Mulita will keep working for browsing, rating, +color labels, picks, heaps, and the (soft) discard flag, but the +following will return an OS error: - `PATCH /photos/{id}` with a new `filename` (rename) +- `POST /photos/move` (bulk move) - `DELETE /discard/empty` (file unlinks) -- Future move / copy endpoints **Heads up**: with `:rw`, Mulita has full write access to whatever -host directory you mount under `~/Pictures`. Treat the same way you -would Lightroom's catalog folder. +host directory you mount. Treat the same way you would Lightroom's +catalog folder. ## Architecture @@ -126,11 +169,12 @@ npm run dev ## Configuration -Edit `mulita.yml` to configure: -- Source photo directories -- Thumbnail sizes and quality -- Scanner settings -- Performance tuning +Source roots are managed by the UI / API (the database owns them). Edit +`mulita.yml` to configure operational settings only: + +- Thumbnail sizes, quality, and format +- Scanner behaviour (watch, batch size, initial scan) +- Performance tuning (concurrency, cache TTLs, DB pool) ## Performance diff --git a/backend/Dockerfile b/backend/Dockerfile index 1268956..617108d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -30,7 +30,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . # Create necessary directories -RUN mkdir -p /data/thumbs /data/db /data/trash /app/config +RUN mkdir -p /data/thumbs /data/db /data/proxies /app/config # Expose port EXPOSE 8000 diff --git a/backend/app/config.py b/backend/app/config.py index b9dc749..d7128b7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -3,8 +3,7 @@ Application configuration using Pydantic Settings """ from pydantic_settings import BaseSettings from pydantic import BaseModel, Field -from typing import List, Optional -import os +from typing import Optional import yaml from pathlib import Path @@ -23,16 +22,6 @@ class ScannerSettings(BaseModel): batch_size: int = 100 concurrent_workers: int = 4 -class SourceRoot(BaseModel): - """Source root directory configuration""" - name: str - path: str - -class TrashSettings(BaseModel): - """Trash settings""" - path: str = "/data/trash" - auto_empty_days: Optional[int] = 30 - class PerformanceSettings(BaseModel): """Performance tuning settings""" max_concurrent_thumbnails: int = 10 @@ -41,11 +30,11 @@ class PerformanceSettings(BaseModel): db_pool_recycle: int = 3600 class MulitaConfig(BaseModel): - """Main configuration from YAML file""" - source_roots: List[SourceRoot] = [] + """Main configuration from YAML file. Source roots and the discard + workflow are owned by the database now — only operational settings + live here.""" thumbnails: ThumbnailSettings = ThumbnailSettings() scanner: ScannerSettings = ScannerSettings() - trash: TrashSettings = TrashSettings() performance: PerformanceSettings = PerformanceSettings() class Settings(BaseSettings): @@ -109,19 +98,11 @@ class Settings(BaseSettings): @property def scanner(self) -> ScannerSettings: return self.config.scanner - - @property - def trash(self) -> TrashSettings: - return self.config.trash - + @property def performance(self) -> PerformanceSettings: return self.config.performance - - @property - def source_roots(self) -> List[SourceRoot]: - return self.config.source_roots - + class Config: env_file = ".env" case_sensitive = False diff --git a/backend/app/main.py b/backend/app/main.py index fa8cc73..e64ac93 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,7 +12,7 @@ import os from app.config import settings from app.database import init_db from app.routers import photos, folders, heaps, tags, discard, library -from app.services.scanner import start_initial_scan +from app.services.scanner import start_initial_scan, bootstrap_default_source_root from app.services.cleanup import cleanup_data_integrity # Configure logging @@ -30,6 +30,14 @@ async def lifespan(app: FastAPI): # Initialize database await init_db() + # First-boot convenience: if there are no source roots in the DB yet, + # create one for the default /photos mount so the user sees their + # library immediately without configuring anything in the UI. + try: + await bootstrap_default_source_root() + except Exception as e: + logger.error(f"Bootstrap source root failed (continuing): {e}") + # One-shot cleanup of duplicate source_roots / folders left over from # earlier scanner versions that didn't normalize paths. Idempotent. try: diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py index 887b4fe..28b2da9 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -1,15 +1,21 @@ """ -Library API router for stats and scanning +Library API router for stats, scanning, and directory browsing """ -from fastapi import APIRouter, Depends +import os +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.models import Photo +from app.models import Photo, SourceRoot 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""" @@ -38,6 +44,89 @@ 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/scanner.py b/backend/app/services/scanner.py index 56c5082..f55ac25 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -1,22 +1,63 @@ """ -Scanner service for initial library scan +Scanner service for initial library scan and one-time bootstrap of the +default source root on first boot. """ +import os import logging +from sqlalchemy import select + +from app.database import AsyncSessionLocal +from app.models import SourceRoot from app.tasks.scan import scan_all_source_roots, watch_folders from app.config import settings logger = logging.getLogger(__name__) +# The single host → container mount path. The compose file mounts whatever +# the user set as PHOTO_DIRS at this path. +DEFAULT_LIBRARY_PATH = "/photos" +DEFAULT_LIBRARY_NAME = "Library" + + +async def bootstrap_default_source_root() -> None: + """If no source roots exist in the DB, create one pointing at the default + library mount. Lets a fresh install pick up photos with zero + configuration: the user only needs to set PHOTO_DIRS in .env. + """ + if not os.path.isdir(DEFAULT_LIBRARY_PATH): + logger.warning( + f"Default library path {DEFAULT_LIBRARY_PATH} is not mounted; " + "set PHOTO_DIRS in .env and recreate the container." + ) + return + + async with AsyncSessionLocal() as session: + result = await session.execute(select(SourceRoot)) + if result.scalars().first() is not None: + return # Already have at least one source root, leave it alone. + + source_root = SourceRoot( + name=DEFAULT_LIBRARY_NAME, + path=DEFAULT_LIBRARY_PATH, + ) + session.add(source_root) + await session.commit() + logger.info( + f"Bootstrapped default source root: {DEFAULT_LIBRARY_NAME} → " + f"{DEFAULT_LIBRARY_PATH}" + ) + + async def start_initial_scan(): """Start the initial library scan""" try: # Queue scan of all source roots scan_all_source_roots.delay() - + # Start folder watcher if configured if settings.scanner.watch: watch_folders.delay() - + logger.info("Initial scan queued successfully") except Exception as e: - logger.error(f"Failed to start initial scan: {e}") \ No newline at end of file + logger.error(f"Failed to start initial scan: {e}") diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index ad691eb..0a39c71 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -250,12 +250,21 @@ async def get_or_create_folder(session: AsyncSession, path: str, source_root_id: @shared_task(name='scan_all_source_roots') def scan_all_source_roots(): - """Scan all configured source roots""" - for source_root in settings.source_roots: - if os.path.exists(source_root.path): - scan_folder.delay(source_root.path) - else: - logger.warning(f"Source root path does not exist: {source_root.path}") + """Scan every active source root currently registered in the DB.""" + return asyncio.run(_scan_all_source_roots_async()) + + +async def _scan_all_source_roots_async(): + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712 + ) + source_roots = result.scalars().all() + for sr in source_roots: + if os.path.exists(sr.path): + scan_folder.delay(sr.path, sr.id) + else: + logger.warning(f"Source root path does not exist: {sr.path}") @shared_task(name='watch_folders') def watch_folders(): diff --git a/docker-compose.yml b/docker-compose.yml index e8978e8..b6a89a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,16 +23,15 @@ services: - "8001:8000" volumes: - ./mulita.yml:/app/config/mulita.yml:ro + # The single host → container mount for your photo library. Set + # PHOTO_DIRS in .env to your library root. Mounted :rw because file + # operations (rename, move, empty discard pile) need to mutate the + # filesystem; flip to :ro for a strict read-only library and the + # write endpoints will return EROFS. - ${PHOTO_DIRS:-./photos}:/photos:rw - # NOTE: read-write — file operations (rename, move, discard, - # empty discard pile) need to mutate the filesystem. Flip to :ro - # if you want a strict read-only library; the rename / move / - # delete endpoints will then return EROFS. - - ~/Pictures:/host/Pictures:rw - thumbs_data:/data/thumbs - proxies_data:/data/proxies - db_data:/data/db - - trash_data:/data/trash environment: - DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db - REDIS_URL=redis://redis:6379 @@ -54,12 +53,9 @@ services: volumes: - ./mulita.yml:/app/config/mulita.yml:ro - ${PHOTO_DIRS:-./photos}:/photos:rw - # See backend service for the rationale on :rw. - - ~/Pictures:/host/Pictures:rw - thumbs_data:/data/thumbs - proxies_data:/data/proxies - db_data:/data/db - - trash_data:/data/trash environment: - DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db - REDIS_URL=redis://redis:6379 @@ -94,5 +90,4 @@ volumes: thumbs_data: proxies_data: db_data: - trash_data: redis_data: \ No newline at end of file diff --git a/frontend/src/components/dialogs/AddSourceFolderDialog.tsx b/frontend/src/components/dialogs/AddSourceFolderDialog.tsx index 4971360..a2682d0 100644 --- a/frontend/src/components/dialogs/AddSourceFolderDialog.tsx +++ b/frontend/src/components/dialogs/AddSourceFolderDialog.tsx @@ -1,6 +1,14 @@ -import { useState } from 'react' -import { X, FolderPlus, AlertCircle } from 'lucide-react' +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 @@ -8,147 +16,204 @@ interface AddSourceFolderDialogProps { onAdd: (path: string, recursive: boolean) => Promise } -export function AddSourceFolderDialog({ isOpen, onClose, onAdd }: AddSourceFolderDialogProps) { - const [folderPath, setFolderPath] = useState('') - const [recursive, setRecursive] = useState(true) - const [isLoading, setIsLoading] = useState(false) +/** + * 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) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!folderPath.trim()) { - setError('Please enter a folder path') - return - } + // 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]) - setIsLoading(true) + const loadPath = async (path?: string) => { + setLoading(true) setError(null) - try { - await onAdd(folderPath.trim(), recursive) - setFolderPath('') - setRecursive(true) - onClose() - } catch (err: any) { - setError(err.message || 'Failed to add source folder') + const data = await library.browse(path) + setBrowse(data) + } catch (e: any) { + setError( + e?.response?.data?.detail || + e?.message || + 'Failed to load directory' + ) } finally { - setIsLoading(false) + 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 (!isLoading) { - setFolderPath('') - setError(null) - onClose() - } + if (adding) return + setBrowse(null) + setError(null) + onClose() } if (!isOpen) return null return (
- {/* Backdrop */} -
- - {/* Dialog */} -
+ +
{/* Header */}
-

Add Source Folder

+

+ Add source folder +

- {/* Form */} -
- {/* Path Input */} -
- - setFolderPath(e.target.value)} - placeholder="/host/Pictures/your-folder" - disabled={isLoading} - className={clsx( - 'w-full rounded border bg-bg px-3 py-2 text-sm text-text placeholder-text-faint', - 'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary', - 'disabled:opacity-50', - error ? 'border-reject' : 'border-border' - )} - /> -
-

- Use container paths. Your Pictures folder is available at: -

- - /host/Pictures/ - -

- Example: /host/Pictures/MulitaTest -

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

+ + +
) -} \ No newline at end of file +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b534b58..f825e21 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -113,6 +113,18 @@ 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') @@ -128,6 +140,15 @@ 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 diff --git a/mulita.yml b/mulita.yml index e916d96..c7a954a 100644 --- a/mulita.yml +++ b/mulita.yml @@ -1,10 +1,8 @@ # Mulita configuration file - -source_roots: - - name: "Main Library" - path: /photos/main - - name: "iPhone Imports" - path: /photos/iphone +# +# Source roots and discard handling are owned by the database — manage them +# from the UI (left sidebar → "+ Add Source Folder") or via the API. Only +# operational tuning lives here. thumbnails: small: 240 # px, longest edge @@ -19,12 +17,8 @@ scanner: batch_size: 100 concurrent_workers: 4 -trash: - path: /data/trash - auto_empty_days: 30 # auto-delete after 30 days in trash - performance: max_concurrent_thumbnails: 10 cache_ttl: 3600 db_pool_size: 20 - db_pool_recycle: 3600 \ No newline at end of file + db_pool_recycle: 3600