refactor: config-driven libraries; drop folder-add UI

The frontend AddSourceFolderDialog let users register source roots
from inside the app, but with the bootstrap auto-creating one for
the /photos mount on first boot, the dialog was redundant in the
common case and confusing in every other (users had to know which
container path corresponded to their host directory). Going
config-driven matches Plex/Photoprism/Immich and matches the
mental model "the docker mount IS the library".

Frontend
- Deleted components/dialogs/AddSourceFolderDialog.tsx entirely.
- LeftSidebar drops the "+ Add Source Folder" button + bottom-bar
  layout, the addFolderMutation, the dead Plus action button on
  the (no-longer-existing) folders/heaps tree headers, and the
  Plus icon import.
- api.ts: removed sourceFolders.add(), library.browse(), and the
  BrowseChild / BrowseResponse types. The remaining sourceFolders
  surface is read-only (list + manual scan).
- LeftSidebar bottom strip is now just the "Scan all folders"
  button when there's at least one source root.

Backend
- Dropped POST /folders (no consumers) along with FolderCreate /
  FolderResponse pydantic models. The folders router header now
  documents the config-driven approach.
- Dropped GET /library/browse (no consumers). Removed the unused
  os/HTTPException/SourceRoot imports it brought in.
- cleanup_data_integrity now also walks the source roots and logs
  a warning for any whose path is missing on disk. Doesn't auto-
  delete (a missing path could be a temporarily unmounted drive)
  but surfaces enough hint to fix it. Returns the count in the
  summary dict alongside merged-duplicates.

Docs
- README "How libraries are managed" section rewritten to spell
  out that mounts ARE source roots, edit .env + restart, no UI for
  managing source roots. New "Changing or adding libraries"
  section walks through the typical edit-restart loop including
  the optional volume-nuke for a clean slate.
- "Adding more libraries" subsection covers multi-mount via
  edited compose with a note that auto-registration is roadmap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 00:44:48 +02:00
parent 204d2bf2a8
commit 320107841b
8 changed files with 65 additions and 482 deletions

View File

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

View File

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