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:
13
.env
13
.env
@@ -1,7 +1,14 @@
|
|||||||
# Environment variables for Mulita
|
# Environment variables for Mulita
|
||||||
|
#
|
||||||
# Photo directories to mount (can be multiple paths separated by colon)
|
# Set PHOTO_DIRS to the HOST path of your photo library. The compose file
|
||||||
# Example: /path/to/photos1:/path/to/photos2
|
# 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
|
PHOTO_DIRS=./photos
|
||||||
|
|
||||||
# Redis configuration
|
# Redis configuration
|
||||||
|
|||||||
110
README.md
110
README.md
@@ -34,53 +34,96 @@ A self-hosted, Docker-deployed photo management application inspired by Lightroo
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Docker and Docker Compose
|
- Docker and Docker Compose
|
||||||
- Photo directories to mount
|
|
||||||
|
|
||||||
### Setup
|
### Setup (one variable)
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repo:
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd muleimage
|
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
|
```bash
|
||||||
# Edit .env file
|
docker compose up -d
|
||||||
PHOTO_DIRS=/path/to/your/photos
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start the application:
|
4. Open `http://localhost:3000`. On first boot Mulita will:
|
||||||
```bash
|
- Mount your `PHOTO_DIRS` at `/photos` inside the container
|
||||||
docker-compose up -d
|
- 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,
|
The default mount is `:rw` because file operations (rename, move,
|
||||||
discard, empty discard pile) need to mutate the filesystem under your
|
empty discard pile) need to mutate the filesystem. If you want a
|
||||||
photo mounts. The default `docker-compose.yml` mounts:
|
strict read-only library — pointing at a network share, an
|
||||||
|
authoritative archive, etc. — flip `:rw` to `:ro` in
|
||||||
- `${PHOTO_DIRS}` → `/photos` (read-write)
|
`docker-compose.yml`. Mulita will keep working for browsing, rating,
|
||||||
- `~/Pictures` → `/host/Pictures` (**read-write** by default so file
|
color labels, picks, heaps, and the (soft) discard flag, but the
|
||||||
operations work on your system Pictures folder out of the box)
|
following will return an OS error:
|
||||||
|
|
||||||
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`):
|
|
||||||
|
|
||||||
- `PATCH /photos/{id}` with a new `filename` (rename)
|
- `PATCH /photos/{id}` with a new `filename` (rename)
|
||||||
|
- `POST /photos/move` (bulk move)
|
||||||
- `DELETE /discard/empty` (file unlinks)
|
- `DELETE /discard/empty` (file unlinks)
|
||||||
- Future move / copy endpoints
|
|
||||||
|
|
||||||
**Heads up**: with `:rw`, Mulita has full write access to whatever
|
**Heads up**: with `:rw`, Mulita has full write access to whatever
|
||||||
host directory you mount under `~/Pictures`. Treat the same way you
|
host directory you mount. Treat the same way you would Lightroom's
|
||||||
would Lightroom's catalog folder.
|
catalog folder.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -126,11 +169,12 @@ npm run dev
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Edit `mulita.yml` to configure:
|
Source roots are managed by the UI / API (the database owns them). Edit
|
||||||
- Source photo directories
|
`mulita.yml` to configure operational settings only:
|
||||||
- Thumbnail sizes and quality
|
|
||||||
- Scanner settings
|
- Thumbnail sizes, quality, and format
|
||||||
- Performance tuning
|
- Scanner behaviour (watch, batch size, initial scan)
|
||||||
|
- Performance tuning (concurrency, cache TTLs, DB pool)
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create necessary directories
|
# 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 port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ Application configuration using Pydantic Settings
|
|||||||
"""
|
"""
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
import os
|
|
||||||
import yaml
|
import yaml
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -23,16 +22,6 @@ class ScannerSettings(BaseModel):
|
|||||||
batch_size: int = 100
|
batch_size: int = 100
|
||||||
concurrent_workers: int = 4
|
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):
|
class PerformanceSettings(BaseModel):
|
||||||
"""Performance tuning settings"""
|
"""Performance tuning settings"""
|
||||||
max_concurrent_thumbnails: int = 10
|
max_concurrent_thumbnails: int = 10
|
||||||
@@ -41,11 +30,11 @@ class PerformanceSettings(BaseModel):
|
|||||||
db_pool_recycle: int = 3600
|
db_pool_recycle: int = 3600
|
||||||
|
|
||||||
class MulitaConfig(BaseModel):
|
class MulitaConfig(BaseModel):
|
||||||
"""Main configuration from YAML file"""
|
"""Main configuration from YAML file. Source roots and the discard
|
||||||
source_roots: List[SourceRoot] = []
|
workflow are owned by the database now — only operational settings
|
||||||
|
live here."""
|
||||||
thumbnails: ThumbnailSettings = ThumbnailSettings()
|
thumbnails: ThumbnailSettings = ThumbnailSettings()
|
||||||
scanner: ScannerSettings = ScannerSettings()
|
scanner: ScannerSettings = ScannerSettings()
|
||||||
trash: TrashSettings = TrashSettings()
|
|
||||||
performance: PerformanceSettings = PerformanceSettings()
|
performance: PerformanceSettings = PerformanceSettings()
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -109,19 +98,11 @@ class Settings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def scanner(self) -> ScannerSettings:
|
def scanner(self) -> ScannerSettings:
|
||||||
return self.config.scanner
|
return self.config.scanner
|
||||||
|
|
||||||
@property
|
|
||||||
def trash(self) -> TrashSettings:
|
|
||||||
return self.config.trash
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def performance(self) -> PerformanceSettings:
|
def performance(self) -> PerformanceSettings:
|
||||||
return self.config.performance
|
return self.config.performance
|
||||||
|
|
||||||
@property
|
|
||||||
def source_roots(self) -> List[SourceRoot]:
|
|
||||||
return self.config.source_roots
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
case_sensitive = False
|
case_sensitive = False
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import os
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.routers import photos, folders, heaps, tags, discard, library
|
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
|
from app.services.cleanup import cleanup_data_integrity
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -30,6 +30,14 @@ async def lifespan(app: FastAPI):
|
|||||||
# Initialize database
|
# Initialize database
|
||||||
await init_db()
|
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
|
# One-shot cleanup of duplicate source_roots / folders left over from
|
||||||
# earlier scanner versions that didn't normalize paths. Idempotent.
|
# earlier scanner versions that didn't normalize paths. Idempotent.
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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 import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Photo
|
from app.models import Photo, SourceRoot
|
||||||
|
|
||||||
router = APIRouter()
|
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")
|
@router.get("/stats")
|
||||||
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||||
"""Get library statistics"""
|
"""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
|
"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")
|
@router.post("/scan")
|
||||||
async def trigger_scan():
|
async def trigger_scan():
|
||||||
"""Trigger full library re-scan"""
|
"""Trigger full library re-scan"""
|
||||||
|
|||||||
@@ -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
|
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.tasks.scan import scan_all_source_roots, watch_folders
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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():
|
async def start_initial_scan():
|
||||||
"""Start the initial library scan"""
|
"""Start the initial library scan"""
|
||||||
try:
|
try:
|
||||||
# Queue scan of all source roots
|
# Queue scan of all source roots
|
||||||
scan_all_source_roots.delay()
|
scan_all_source_roots.delay()
|
||||||
|
|
||||||
# Start folder watcher if configured
|
# Start folder watcher if configured
|
||||||
if settings.scanner.watch:
|
if settings.scanner.watch:
|
||||||
watch_folders.delay()
|
watch_folders.delay()
|
||||||
|
|
||||||
logger.info("Initial scan queued successfully")
|
logger.info("Initial scan queued successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start initial scan: {e}")
|
logger.error(f"Failed to start initial scan: {e}")
|
||||||
|
|||||||
@@ -250,12 +250,21 @@ async def get_or_create_folder(session: AsyncSession, path: str, source_root_id:
|
|||||||
|
|
||||||
@shared_task(name='scan_all_source_roots')
|
@shared_task(name='scan_all_source_roots')
|
||||||
def scan_all_source_roots():
|
def scan_all_source_roots():
|
||||||
"""Scan all configured source roots"""
|
"""Scan every active source root currently registered in the DB."""
|
||||||
for source_root in settings.source_roots:
|
return asyncio.run(_scan_all_source_roots_async())
|
||||||
if os.path.exists(source_root.path):
|
|
||||||
scan_folder.delay(source_root.path)
|
|
||||||
else:
|
async def _scan_all_source_roots_async():
|
||||||
logger.warning(f"Source root path does not exist: {source_root.path}")
|
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')
|
@shared_task(name='watch_folders')
|
||||||
def watch_folders():
|
def watch_folders():
|
||||||
|
|||||||
@@ -23,16 +23,15 @@ services:
|
|||||||
- "8001:8000"
|
- "8001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
- ./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
|
- ${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
|
- thumbs_data:/data/thumbs
|
||||||
- proxies_data:/data/proxies
|
- proxies_data:/data/proxies
|
||||||
- db_data:/data/db
|
- db_data:/data/db
|
||||||
- trash_data:/data/trash
|
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
@@ -54,12 +53,9 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||||
# See backend service for the rationale on :rw.
|
|
||||||
- ~/Pictures:/host/Pictures:rw
|
|
||||||
- thumbs_data:/data/thumbs
|
- thumbs_data:/data/thumbs
|
||||||
- proxies_data:/data/proxies
|
- proxies_data:/data/proxies
|
||||||
- db_data:/data/db
|
- db_data:/data/db
|
||||||
- trash_data:/data/trash
|
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
@@ -94,5 +90,4 @@ volumes:
|
|||||||
thumbs_data:
|
thumbs_data:
|
||||||
proxies_data:
|
proxies_data:
|
||||||
db_data:
|
db_data:
|
||||||
trash_data:
|
|
||||||
redis_data:
|
redis_data:
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { X, FolderPlus, AlertCircle } from 'lucide-react'
|
import {
|
||||||
|
X,
|
||||||
|
FolderPlus,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronUp,
|
||||||
|
Folder,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { library, type BrowseResponse } from '../../services/api'
|
||||||
|
|
||||||
interface AddSourceFolderDialogProps {
|
interface AddSourceFolderDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -8,147 +16,204 @@ interface AddSourceFolderDialogProps {
|
|||||||
onAdd: (path: string, recursive: boolean) => Promise<void>
|
onAdd: (path: string, recursive: boolean) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddSourceFolderDialog({ isOpen, onClose, onAdd }: AddSourceFolderDialogProps) {
|
/**
|
||||||
const [folderPath, setFolderPath] = useState('')
|
* Directory-browser dialog for adding a source folder.
|
||||||
const [recursive, setRecursive] = useState(true)
|
*
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
* 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<BrowseResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [recursive, setRecursive] = useState(true)
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
// Load the root directory (/photos) on first open. Subsequent navigation
|
||||||
e.preventDefault()
|
// (clicking a child or the parent button) calls loadPath directly.
|
||||||
|
useEffect(() => {
|
||||||
if (!folderPath.trim()) {
|
if (!isOpen) return
|
||||||
setError('Please enter a folder path')
|
loadPath(undefined)
|
||||||
return
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}
|
}, [isOpen])
|
||||||
|
|
||||||
setIsLoading(true)
|
const loadPath = async (path?: string) => {
|
||||||
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onAdd(folderPath.trim(), recursive)
|
const data = await library.browse(path)
|
||||||
setFolderPath('')
|
setBrowse(data)
|
||||||
setRecursive(true)
|
} catch (e: any) {
|
||||||
onClose()
|
setError(
|
||||||
} catch (err: any) {
|
e?.response?.data?.detail ||
|
||||||
setError(err.message || 'Failed to add source folder')
|
e?.message ||
|
||||||
|
'Failed to load directory'
|
||||||
|
)
|
||||||
} finally {
|
} 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 = () => {
|
const handleClose = () => {
|
||||||
if (!isLoading) {
|
if (adding) return
|
||||||
setFolderPath('')
|
setBrowse(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
{/* Backdrop */}
|
<div
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dialog */}
|
<div className="relative z-10 w-full max-w-lg rounded-lg border border-border bg-surface p-6 shadow-xl">
|
||||||
<div className="relative z-10 w-full max-w-md rounded-lg bg-surface border border-border p-6 shadow-xl">
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FolderPlus className="h-5 w-5 text-primary" />
|
<FolderPlus className="h-5 w-5 text-primary" />
|
||||||
<h2 className="text-lg font-semibold text-text">Add Source Folder</h2>
|
<h2 className="text-lg font-semibold text-text">
|
||||||
|
Add source folder
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
disabled={isLoading}
|
disabled={adding}
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-50"
|
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Current path + parent nav */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
{/* Path Input */}
|
<button
|
||||||
<div>
|
onClick={() => browse?.parent && loadPath(browse.parent)}
|
||||||
<label htmlFor="folderPath" className="mb-1 block text-sm text-text-muted">
|
disabled={!browse?.parent || loading || adding}
|
||||||
Folder Path
|
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-30"
|
||||||
</label>
|
title="Up one folder"
|
||||||
<input
|
>
|
||||||
id="folderPath"
|
<ChevronUp className="h-4 w-4" />
|
||||||
type="text"
|
</button>
|
||||||
value={folderPath}
|
<code className="flex-1 truncate rounded bg-surface-2 px-2 py-1 font-mono text-xs text-text">
|
||||||
onChange={(e) => setFolderPath(e.target.value)}
|
{browse?.path ?? '…'}
|
||||||
placeholder="/host/Pictures/your-folder"
|
</code>
|
||||||
disabled={isLoading}
|
</div>
|
||||||
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'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="mt-1 space-y-1">
|
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
Use container paths. Your Pictures folder is available at:
|
|
||||||
</p>
|
|
||||||
<code className="block text-xs bg-surface-2 px-2 py-1 rounded text-primary">
|
|
||||||
/host/Pictures/
|
|
||||||
</code>
|
|
||||||
<p className="text-xs text-text-faint">
|
|
||||||
Example: /host/Pictures/MulitaTest
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recursive Checkbox */}
|
{/* Children list */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="mb-4 h-64 overflow-y-auto rounded border border-border bg-bg">
|
||||||
<input
|
{loading && (
|
||||||
id="recursive"
|
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||||
type="checkbox"
|
Loading…
|
||||||
checked={recursive}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<label htmlFor="recursive" className="text-sm text-text">
|
|
||||||
Include subfolders
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
|
|
||||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!loading && browse && browse.children.length === 0 && (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||||
|
No subfolders here
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading &&
|
||||||
|
browse &&
|
||||||
|
browse.children.map((child) => (
|
||||||
|
<button
|
||||||
|
key={child.path}
|
||||||
|
onClick={() => loadPath(child.path)}
|
||||||
|
disabled={adding}
|
||||||
|
className="flex w-full items-center gap-2 border-b border-border px-3 py-1.5 text-left text-sm text-text last:border-b-0 hover:bg-surface-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Folder className="h-4 w-4 text-text-muted" />
|
||||||
|
<span className="flex-1 truncate">{child.name}</span>
|
||||||
|
{child.is_existing_root && (
|
||||||
|
<span className="flex items-center gap-1 rounded bg-primary/20 px-1.5 py-0.5 text-[10px] text-primary">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
Added
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Recursive toggle */}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<button
|
<input
|
||||||
type="button"
|
id="recursive"
|
||||||
onClick={handleClose}
|
type="checkbox"
|
||||||
disabled={isLoading}
|
checked={recursive}
|
||||||
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
onChange={(e) => setRecursive(e.target.checked)}
|
||||||
>
|
disabled={adding}
|
||||||
Cancel
|
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
|
||||||
</button>
|
/>
|
||||||
<button
|
<label htmlFor="recursive" className="text-sm text-text">
|
||||||
type="submit"
|
Include subfolders
|
||||||
disabled={isLoading || !folderPath.trim()}
|
</label>
|
||||||
className="rounded bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90 disabled:opacity-50"
|
</div>
|
||||||
>
|
|
||||||
{isLoading ? 'Adding...' : 'Add Folder'}
|
{/* Error */}
|
||||||
</button>
|
{error && (
|
||||||
|
<div className="mb-3 flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
|
||||||
|
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="flex-1 text-xs text-text-muted">
|
||||||
|
{browse?.is_existing_root
|
||||||
|
? 'This folder is already a source root.'
|
||||||
|
: 'Adds the folder shown above as a source root.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={adding}
|
||||||
|
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={
|
||||||
|
!browse || adding || loading || browse.is_existing_root
|
||||||
|
}
|
||||||
|
className={clsx(
|
||||||
|
'rounded bg-primary px-4 py-2 text-sm text-white',
|
||||||
|
'hover:bg-primary/90 disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{adding ? 'Adding…' : 'Add this folder'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,18 @@ export const photos = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Library API
|
// 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 = {
|
export const library = {
|
||||||
scan: async () => {
|
scan: async () => {
|
||||||
const response = await api.post('/library/scan')
|
const response = await api.post('/library/scan')
|
||||||
@@ -128,6 +140,15 @@ export const library = {
|
|||||||
const response = await api.get('/library/stats')
|
const response = await api.get('/library/stats')
|
||||||
return response.data
|
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<BrowseResponse> => {
|
||||||
|
const response = await api.get('/library/browse', {
|
||||||
|
params: path ? { path } : undefined,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heaps API
|
// Heaps API
|
||||||
|
|||||||
16
mulita.yml
16
mulita.yml
@@ -1,10 +1,8 @@
|
|||||||
# Mulita configuration file
|
# Mulita configuration file
|
||||||
|
#
|
||||||
source_roots:
|
# Source roots and discard handling are owned by the database — manage them
|
||||||
- name: "Main Library"
|
# from the UI (left sidebar → "+ Add Source Folder") or via the API. Only
|
||||||
path: /photos/main
|
# operational tuning lives here.
|
||||||
- name: "iPhone Imports"
|
|
||||||
path: /photos/iphone
|
|
||||||
|
|
||||||
thumbnails:
|
thumbnails:
|
||||||
small: 240 # px, longest edge
|
small: 240 # px, longest edge
|
||||||
@@ -19,12 +17,8 @@ scanner:
|
|||||||
batch_size: 100
|
batch_size: 100
|
||||||
concurrent_workers: 4
|
concurrent_workers: 4
|
||||||
|
|
||||||
trash:
|
|
||||||
path: /data/trash
|
|
||||||
auto_empty_days: 30 # auto-delete after 30 days in trash
|
|
||||||
|
|
||||||
performance:
|
performance:
|
||||||
max_concurrent_thumbnails: 10
|
max_concurrent_thumbnails: 10
|
||||||
cache_ttl: 3600
|
cache_ttl: 3600
|
||||||
db_pool_size: 20
|
db_pool_size: 20
|
||||||
db_pool_recycle: 3600
|
db_pool_recycle: 3600
|
||||||
|
|||||||
Reference in New Issue
Block a user