feat: simplify folder setup — single mount, auto bootstrap, browser dialog

Cleans up the maze of overlapping ways folders entered the app, plus
removes the dead trash plumbing left over from the soft-discard
refactor.

Setup model (now)
- ONE env var: PHOTO_DIRS in .env, set to the host path of your
  library. Compose mounts that at /photos. That's the entire setup.
- On first boot, the backend auto-creates a SourceRoot row named
  "Library" pointing at /photos so the user sees their photos
  immediately without configuring anything.
- Source roots and discard live in the database; mulita.yml only
  carries operational settings (thumbnails, scanner, performance).
- The "Add Source Folder" dialog is now a directory browser
  restricted server-side to /photos and any existing source root —
  the user clicks through actual mounted directories instead of
  typing container paths they can't possibly know.

Backend
- New services/scanner.bootstrap_default_source_root(): if no
  SourceRoot rows exist and /photos is mounted, create one. Wired
  into the lifespan handler before cleanup + initial scan.
- New GET /library/browse?path= returning the immediate child
  directories of `path`, validated to live under one of the allowed
  roots (default mount + every active SourceRoot). Hidden entries
  are filtered. Children are tagged with is_existing_root so the UI
  can show an "Added" badge. Returns parent path for up-nav, or
  null when at the top of the allowed scope.
- scan_all_source_roots now reads from the DB instead of the YAML
  config so DB-managed source roots are honoured by initial scan.
- Dropped the placeholder source_roots block from mulita.yml — the
  paths /photos/main and /photos/iphone never existed and just
  produced startup warnings.
- Dropped TrashSettings, settings.trash, settings.source_roots,
  and the SourceRoot pydantic model from config.py. Soft discard
  has owned this for a while; it was dead code.

Compose
- Single ${PHOTO_DIRS:-./photos}:/photos:rw mount in both backend
  and worker.
- Removed the hardcoded ~/Pictures:/host/Pictures:rw mount — the
  PHOTO_DIRS variable is the single source of truth now.
- Removed the trash_data named volume + mounts (no consumers).
- backend/Dockerfile no longer creates /data/trash; it now creates
  /data/proxies (which the proxy endpoint actually uses).

Frontend
- AddSourceFolderDialog rewritten as a directory tree picker:
  loads /library/browse on open, lets the user navigate up via a
  ChevronUp button or down by clicking subfolders, shows the
  current path inline, and adds whatever directory is currently
  shown. Existing source roots are tagged "Added" so the user
  knows what's already registered. Errors from the backend (e.g.
  trying to navigate outside the allowed scope) surface inline.
- New library.browse() helper + BrowseChild / BrowseResponse types
  in services/api.ts.

Docs
- README Quick Start rewritten around the single PHOTO_DIRS env
  var, with macOS/Linux/Windows examples.
- New "How mounted folders and source folders relate" section that
  spells out the two-layer model (mount = visibility, source root
  = scanning) so the most common confusion is addressed up front.
- Added a "Read-only libraries" subsection that lists exactly which
  endpoints fail under :ro.
- "Configuration" section reframed: source roots are managed by the
  UI/API now, mulita.yml is operational settings only.
- .env file now has examples for the common host paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 00:27:21 +02:00
parent a0c41e38d3
commit 204d2bf2a8
12 changed files with 457 additions and 203 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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"""

View File

@@ -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}")
logger.error(f"Failed to start initial scan: {e}")

View File

@@ -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():