0d3fe8d907
First Library Management feature. On the Library album modal, "Delete album" (two-step confirm) removes the album: - deletes the folder on disk (guarded to only ever remove a path strictly inside the library root, LYRA_LIBRARY_ROOT, default /music), - drops the LibraryItem, - sets the matching MonitoredRelease monitored=false + currentQualityClass=null so the monitor won't immediately re-download it (re-Want later to restore). Requires the web container to see the library, so docker-compose now bind-mounts MUSIC_DIR at /music (rw) for web too. New DELETE /api/library/[id] (auth-gated, path-guarded). web 157 tests (delete happy-path + 404 + outside-root guard). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
5.1 KiB
YAML
127 lines
5.1 KiB
YAML
services:
|
|
db:
|
|
image: postgres:17
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
POSTGRES_DB: ${POSTGRES_DB}
|
|
volumes:
|
|
- postgres-data:/var/lib/postgresql/data
|
|
ports:
|
|
- "5432:5432"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
web:
|
|
build: ./web
|
|
restart: unless-stopped
|
|
environment:
|
|
DATABASE_URL: ${DATABASE_URL}
|
|
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
|
|
# Optional shared-password gate for the whole UI/API. Unset ⇒ app is open. See README.
|
|
LYRA_PASSWORD: ${LYRA_PASSWORD:-}
|
|
# Next's standalone server binds to HOSTNAME; default is the container hostname (its own
|
|
# IP only), so loopback healthchecks can't reach it. Bind all interfaces instead.
|
|
HOSTNAME: 0.0.0.0
|
|
# Library root inside the container (matches the /music mount below) — used by the
|
|
# library-management delete to locate + guard which folders it may remove.
|
|
LYRA_LIBRARY_ROOT: /music
|
|
volumes:
|
|
# Library management (delete albums, and later manual import) needs the web to read/write
|
|
# the library, so mount it the same way the worker does.
|
|
- ${MUSIC_DIR:-./music}:/music
|
|
ports:
|
|
# host:container — host 8770 avoids the Quill app on 3000 (and Lidarr on 8686)
|
|
- "8770:3000"
|
|
healthcheck:
|
|
# unauthenticated liveness endpoint; node 22 has global fetch (no curl/wget in the image)
|
|
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 20s
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
|
|
worker:
|
|
build: ./worker
|
|
restart: unless-stopped
|
|
environment:
|
|
DATABASE_URL: ${DATABASE_URL}
|
|
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
|
|
STAGING_ROOT: /staging
|
|
SLSKD_DOWNLOADS_ROOT: /slskd-downloads
|
|
# Qobuz's CDN also resolves to IPv6, but the container has no IPv6 route
|
|
# ("Network is unreachable"); disable IPv6 so downloads use IPv4 only.
|
|
sysctls:
|
|
- net.ipv6.conf.all.disable_ipv6=1
|
|
volumes:
|
|
- ${MUSIC_DIR:-./music}:/music
|
|
# Per-job download staging on its own volume. Defaults to MUSIC_DIR/.staging (prior
|
|
# behavior); set STAGING_DIR to a fast local path when MUSIC_DIR is a network share.
|
|
- ${STAGING_DIR:-${MUSIC_DIR:-./music}/.staging}:/staging
|
|
# slskd (an EXTERNAL daemon, not in this compose stack) writes finished Soulseek
|
|
# downloads into its own directory; the worker moves them out of here into staging.
|
|
# Point SLSKD_DOWNLOADS_DIR at slskd's downloads dir on the host (must be the SAME
|
|
# path slskd writes to — set it once Lyra runs on the slskd host). Defaults to an
|
|
# empty local dir so the mount is always valid; Soulseek delivery only works once
|
|
# this points at the real slskd downloads directory.
|
|
- ${SLSKD_DOWNLOADS_DIR:-./slskd-downloads}:/slskd-downloads
|
|
healthcheck:
|
|
# the worker touches /tmp/worker.alive every ~15s from a background thread (survives long
|
|
# downloads); unhealthy if it hasn't been touched in 90s (process wedged/exited).
|
|
test: ["CMD-SHELL", "test $(( $(date +%s) - $(stat -c %Y /tmp/worker.alive 2>/dev/null || echo 0) )) -lt 90"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 20s
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
|
|
# Scheduled Postgres backups. ALL durable state (library, monitored/wanted lists,
|
|
# discovery data, config, AND the encrypted credentials) lives in the single
|
|
# postgres-data volume — one volume loss = total loss. This sidecar pg_dumps on an
|
|
# interval (immediately on start, then every BACKUP_INTERVAL_SECONDS), keeping the
|
|
# last BACKUP_KEEP dumps in ${BACKUP_DIR:-./backups}. Restore steps: see README.
|
|
# For off-box safety, point BACKUP_DIR at a synced/NAS path or rsync ./backups away.
|
|
db-backup:
|
|
image: postgres:17
|
|
restart: unless-stopped
|
|
environment:
|
|
PGHOST: db
|
|
PGUSER: ${POSTGRES_USER}
|
|
PGPASSWORD: ${POSTGRES_PASSWORD}
|
|
PGDATABASE: ${POSTGRES_DB}
|
|
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
|
|
BACKUP_KEEP: ${BACKUP_KEEP:-7}
|
|
volumes:
|
|
- ${BACKUP_DIR:-./backups}:/backups
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
entrypoint: ["/bin/sh", "-c"]
|
|
command:
|
|
- |
|
|
echo "db-backup: starting (interval=$${BACKUP_INTERVAL_SECONDS}s keep=$${BACKUP_KEEP})"
|
|
while true; do
|
|
ts=$$(date +%Y%m%d-%H%M%S)
|
|
out="/backups/lyra-$${ts}.dump"
|
|
if pg_dump -Fc -f "$${out}.tmp" 2>/tmp/err; then
|
|
mv "$${out}.tmp" "$${out}"
|
|
echo "db-backup: wrote $${out} ($$(du -h "$${out}" | cut -f1))"
|
|
else
|
|
echo "db-backup: pg_dump FAILED: $$(cat /tmp/err)"; rm -f "$${out}.tmp"
|
|
fi
|
|
ls -1t /backups/lyra-*.dump 2>/dev/null | tail -n +$$(( $${BACKUP_KEEP} + 1 )) | xargs -r rm -f
|
|
sleep "$${BACKUP_INTERVAL_SECONDS}"
|
|
done
|
|
|
|
volumes:
|
|
postgres-data:
|