feat(v3): add Dozzle/Uptime Kuma/Gotify; fix Meilisearch catalogue re-index churn

- docker-compose: add dozzle, uptime-kuma, gotify services with Caddy subdomains
  (logs/uptime/push.libnovel.cc); bind-mount dozzle/users.yml for simple auth
- Caddyfile: add site blocks for logs, uptime, push subdomains
- admin layout: add Logs, Uptime, Push tabs to external tools pill
- CatalogueEntry: add Slug field; populated by novelfire scraper
- meili.Client: add BookExists() to check if a book is already indexed
- catalogue_refresh: skip books already present in Meilisearch to prevent
  redundant re-scraping and re-indexing on every runner restart
- docker-compose runner: set RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
This commit is contained in:
Admin
2026-03-23 16:10:35 +05:00
parent 5b27d501af
commit b9b69cee44
8 changed files with 446 additions and 13 deletions

View File

@@ -51,10 +51,10 @@ services:
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-admin} $${MINIO_ROOT_PASSWORD:-changeme123};
mc mb --ignore-existing local/libnovel-chapters;
mc mb --ignore-existing local/libnovel-audio;
mc mb --ignore-existing local/chapters;
mc mb --ignore-existing local/audio;
mc mb --ignore-existing local/avatars;
mc mb --ignore-existing local/libnovel-browse;
mc mb --ignore-existing local/catalogue;
echo 'buckets ready';
"
environment:
@@ -157,6 +157,8 @@ services:
<<: *infra-env
BACKEND_HTTP_ADDR: ":8080"
LOG_LEVEL: "${LOG_LEVEL:-info}"
KOKORO_URL: "${KOKORO_URL:-https://kokoro.kalekber.cc/}"
KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}"
healthcheck:
test: ["CMD", "/healthcheck", "http://localhost:8080/health"]
interval: 15s
@@ -200,8 +202,12 @@ services:
RUNNER_WORKER_ID: "${RUNNER_WORKER_ID:-runner-1}"
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT:-90s}"
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR:-:9091}"
# Suppress the on-startup catalogue walk — catalogue_refresh now skips
# books already in Meilisearch, so a full walk on every restart is wasteful.
# The 24h periodic ticker (CatalogueRefreshInterval) still fires normally.
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
# Kokoro-FastAPI TTS endpoint
KOKORO_URL: "${KOKORO_URL:-}"
KOKORO_URL: "${KOKORO_URL:-https://kokoro.kalekber.cc/}"
KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}"
healthcheck:
# The runner writes /tmp/runner.alive on every poll.
@@ -253,8 +259,64 @@ services:
timeout: 5s
retries: 3
# ─── CrowdSec (threat detection + IP blocking) ───────────────────────────────
# Reads Caddy JSON access logs from the shared caddy_logs volume and enforces
# decisions via the Caddy bouncer plugin.
crowdsec:
image: crowdsecurity/crowdsec:latest
restart: unless-stopped
environment:
GID: "1000"
COLLECTIONS: "crowdsecurity/caddy crowdsecurity/http-dos crowdsecurity/base-http-scenarios"
volumes:
- crowdsec_data:/var/lib/crowdsec/data
- ./crowdsec/acquis.yaml:/etc/crowdsec/acquis.yaml:ro
- caddy_logs:/var/log/caddy:ro
expose:
- "8080"
healthcheck:
test: ["CMD", "cscli", "version"]
interval: 20s
timeout: 10s
retries: 5
# ─── CrowdSec bouncer registration ───────────────────────────────────────────
# One-shot: registers the Caddy bouncer with the CrowdSec LAPI and writes the
# generated API key to crowdsec/.crowdsec.env, which Caddy reads via env_file.
# Uses the Docker socket to exec cscli inside the running crowdsec container.
crowdsec-init:
image: docker:cli
depends_on:
crowdsec:
condition: service_healthy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./crowdsec:/crowdsec-out
entrypoint: >
/bin/sh -c "
out=/crowdsec-out/.crowdsec.env;
existing=$$(grep -s '^CROWDSEC_API_KEY=.' \"$$out\" | cut -d= -f2-);
if [ -n \"$$existing\" ]; then
echo 'crowdsec-init: key already present, skipping registration';
exit 0;
fi;
container=$$(docker ps --filter name=crowdsec --filter status=running --format '{{.Names}}' | grep -v init | head -1);
echo \"crowdsec-init: using container $$container\";
docker exec $$container cscli bouncers delete caddy-bouncer 2>/dev/null || true;
key=$$(docker exec $$container cscli bouncers add caddy-bouncer -o raw 2>&1);
if [ -z \"$$key\" ]; then
echo 'crowdsec-init: ERROR — failed to obtain bouncer key' >&2;
exit 1;
fi;
printf 'CROWDSEC_API_KEY=%s\n' \"$$key\" > \"$$out\";
echo \"crowdsec-init: bouncer key written (key length: $${#key})\";
"
restart: "no"
# ─── Caddy (reverse proxy + automatic HTTPS) ──────────────────────────────────
# Custom build includes github.com/mholt/caddy-ratelimit.
# Custom build includes github.com/mholt/caddy-ratelimit and
# github.com/hslatman/caddy-crowdsec-bouncer/http.
caddy:
build:
context: ./caddy
@@ -265,6 +327,8 @@ services:
condition: service_healthy
ui:
condition: service_healthy
crowdsec-init:
condition: service_completed_successfully
ports:
- "80:80"
- "443:443"
@@ -272,11 +336,15 @@ services:
environment:
DOMAIN: "${DOMAIN:-localhost}"
CADDY_ACME_EMAIL: "${CADDY_ACME_EMAIL:-}"
env_file:
- path: ./crowdsec/.crowdsec.env
required: false
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/errors:/srv/errors:ro
- caddy_data:/data
- caddy_config:/config
- caddy_logs:/var/log/caddy
# ─── Watchtower (auto-redeploy custom services on new images) ────────────────
# Only watches services labelled com.centurylinklabs.watchtower.enable=true.
@@ -292,6 +360,203 @@ services:
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL:-}"
DOCKER_API_VERSION: "1.44"
# ─── Shared PostgreSQL (Fider + GlitchTip + Umami) ───────────────────────────
# A single Postgres instance hosting three separate databases.
# PocketBase uses its own embedded SQLite; this postgres is only for the
# three new services below.
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: "${POSTGRES_USER:-pgadmin}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-changeme_postgres_123}"
POSTGRES_DB: postgres
expose:
- "5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER:-pgadmin}"]
interval: 10s
timeout: 5s
retries: 5
# ─── Postgres database initialisation ────────────────────────────────────────
# One-shot: creates the fider, glitchtip, and umami databases if missing.
postgres-init:
image: postgres:16-alpine
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: "${POSTGRES_PASSWORD:-changeme_postgres_123}"
entrypoint: >
/bin/sh -c "
psql -h postgres -U ${POSTGRES_USER:-pgadmin} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='fider'\" | grep -q 1 ||
psql -h postgres -U ${POSTGRES_USER:-pgadmin} -d postgres -c \"CREATE DATABASE fider\";
psql -h postgres -U ${POSTGRES_USER:-pgadmin} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='glitchtip'\" | grep -q 1 ||
psql -h postgres -U ${POSTGRES_USER:-pgadmin} -d postgres -c \"CREATE DATABASE glitchtip\";
psql -h postgres -U ${POSTGRES_USER:-pgadmin} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='umami'\" | grep -q 1 ||
psql -h postgres -U ${POSTGRES_USER:-pgadmin} -d postgres -c \"CREATE DATABASE umami\";
echo 'postgres-init: databases ready';
"
restart: "no"
# ─── Fider (user feedback & feature requests) ─────────────────────────────────
fider:
image: getfider/fider:stable
restart: unless-stopped
depends_on:
postgres-init:
condition: service_completed_successfully
postgres:
condition: service_healthy
expose:
- "3000"
environment:
BASE_URL: "https://feedback.libnovel.cc"
DATABASE_URL: "postgres://${POSTGRES_USER:-pgadmin}:${POSTGRES_PASSWORD:-changeme_postgres_123}@postgres:5432/fider?sslmode=disable"
JWT_SECRET: "0cd21a84d03b36e730aa6ef5a24c1dd6cb01546187e6ca1161c90e3a167df52d"
# Email: noreply mode — emails are suppressed (logged to stdout).
# Fider still requires SMTP vars to be non-empty even in noreply mode.
EMAIL_NOREPLY: "noreply@libnovel.cc"
EMAIL_SMTP_HOST: "localhost"
EMAIL_SMTP_PORT: "25"
# Disable outbound email — set real SMTP values to enable.
EMAIL_NOREPLY_MODE: "true"
# ─── GlitchTip DB migration (one-shot) ───────────────────────────────────────
glitchtip-migrate:
image: glitchtip/glitchtip:latest
depends_on:
postgres-init:
condition: service_completed_successfully
postgres:
condition: service_healthy
environment:
DATABASE_URL: "postgres://${POSTGRES_USER:-pgadmin}:${POSTGRES_PASSWORD:-changeme_postgres_123}@postgres:5432/glitchtip"
SECRET_KEY: "d4936488eb40bafe5cd79f137133b3f642db5537dd3f80ab4c858984443163be"
GLITCHTIP_DOMAIN: "https://errors.libnovel.cc"
EMAIL_URL: "consolemail://"
DEFAULT_FROM_EMAIL: "errors@libnovel.cc"
VALKEY_URL: "redis://valkey:6379/1"
command: "./manage.py migrate"
restart: "no"
# ─── GlitchTip web (error tracking UI + API) ─────────────────────────────────
glitchtip-web:
image: glitchtip/glitchtip:latest
restart: unless-stopped
depends_on:
glitchtip-migrate:
condition: service_completed_successfully
valkey:
condition: service_healthy
expose:
- "8000"
environment:
DATABASE_URL: "postgres://${POSTGRES_USER:-pgadmin}:${POSTGRES_PASSWORD:-changeme_postgres_123}@postgres:5432/glitchtip"
SECRET_KEY: "d4936488eb40bafe5cd79f137133b3f642db5537dd3f80ab4c858984443163be"
GLITCHTIP_DOMAIN: "https://errors.libnovel.cc"
EMAIL_URL: "consolemail://"
DEFAULT_FROM_EMAIL: "errors@libnovel.cc"
VALKEY_URL: "redis://valkey:6379/1"
PORT: "8000"
ENABLE_USER_REGISTRATION: "false"
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"]
interval: 15s
timeout: 5s
retries: 5
# ─── GlitchTip worker (background task processor) ─────────────────────────────
glitchtip-worker:
image: glitchtip/glitchtip:latest
restart: unless-stopped
depends_on:
glitchtip-migrate:
condition: service_completed_successfully
valkey:
condition: service_healthy
environment:
DATABASE_URL: "postgres://${POSTGRES_USER:-pgadmin}:${POSTGRES_PASSWORD:-changeme_postgres_123}@postgres:5432/glitchtip"
SECRET_KEY: "d4936488eb40bafe5cd79f137133b3f642db5537dd3f80ab4c858984443163be"
GLITCHTIP_DOMAIN: "https://errors.libnovel.cc"
EMAIL_URL: "consolemail://"
DEFAULT_FROM_EMAIL: "errors@libnovel.cc"
VALKEY_URL: "redis://valkey:6379/1"
SERVER_ROLE: "worker"
# ─── Umami (page analytics) ───────────────────────────────────────────────────
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
restart: unless-stopped
depends_on:
postgres-init:
condition: service_completed_successfully
postgres:
condition: service_healthy
expose:
- "3000"
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER:-pgadmin}:${POSTGRES_PASSWORD:-changeme_postgres_123}@postgres:5432/umami"
APP_SECRET: "e4a91a11027acfd0270539a67a405115eda3d0450827415900d775c183b83821"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/heartbeat"]
interval: 15s
timeout: 5s
retries: 5
# ─── Dozzle (Docker log viewer) ───────────────────────────────────────────────
dozzle:
image: amir20/dozzle:latest
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./dozzle/users.yml:/data/users.yml:ro
expose:
- "8080"
environment:
DOZZLE_AUTH_PROVIDER: simple
DOZZLE_HOSTNAME: "logs.libnovel.cc"
healthcheck:
test: ["CMD", "/dozzle", "healthcheck"]
interval: 15s
timeout: 5s
retries: 5
# ─── Uptime Kuma (uptime monitoring) ──────────────────────────────────────────
uptime-kuma:
image: louislam/uptime-kuma:1
restart: unless-stopped
volumes:
- uptime_kuma_data:/app/data
expose:
- "3001"
healthcheck:
test: ["CMD", "extra/healthcheck"]
interval: 15s
timeout: 5s
retries: 5
# ─── Gotify (push notifications) ──────────────────────────────────────────────
gotify:
image: gotify/server:latest
restart: unless-stopped
volumes:
- gotify_data:/app/data
expose:
- "80"
environment:
GOTIFY_DEFAULTUSER_NAME: "${GOTIFY_ADMIN_USER:-admin}"
GOTIFY_DEFAULTUSER_PASS: "${GOTIFY_ADMIN_PASS:-changeme_gotify_123}"
GOTIFY_SERVER_PORT: "80"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
interval: 15s
timeout: 5s
retries: 5
volumes:
minio_data:
pb_data:
@@ -299,3 +564,8 @@ volumes:
valkey_data:
caddy_data:
caddy_config:
caddy_logs:
crowdsec_data:
postgres_data:
uptime_kuma_data:
gotify_data: