direction: right

# ─── External ─────────────────────────────────────────────────────────────────

novelfire: novelfire.net {
  shape: cloud
  style.fill: "#f0f4ff"
  label: "novelfire.net\n(scrape source)"
}

kokoro: Kokoro-FastAPI TTS {
  shape: cloud
  style.fill: "#f0f4ff"
  label: "Kokoro-FastAPI TTS\n(self-hosted · homelab)\nchapter audio"
}

pockettts: pocket-tts {
  shape: cloud
  style.fill: "#f0f4ff"
  label: "pocket-tts\n(self-hosted · homelab)\nvoice sample MP3s"
}

letsencrypt: Let's Encrypt {
  shape: cloud
  style.fill: "#f0f4ff"
  label: "Let's Encrypt\n(ACME TLS-ALPN-01)"
}

browser: Browser / iOS App {
  shape: person
  style.fill: "#fff9e6"
}

# ─── Init containers (one-shot) ───────────────────────────────────────────────

init: Init containers {
  style.fill: "#f5f5f5"
  style.stroke-dash: 4

  minio-init: minio-init {
    shape: rectangle
    label: "minio-init\n(mc: create buckets\n  chapters · audio\n  avatars · catalogue)"
  }

  pb-init: pb-init {
    shape: rectangle
    label: "pb-init\n(bootstrap PocketBase\n  collections + schema)"
  }
}

# ─── Storage ──────────────────────────────────────────────────────────────────

storage: Storage {
  style.fill: "#eaf7ea"

  minio: MinIO {
    shape: cylinder
    label: "MinIO :9000\nbuckets:\n  chapters · audio\n  avatars · catalogue"
  }

  pocketbase: PocketBase {
    shape: cylinder
    label: "PocketBase :8090\ncollections:\n  books · chapters_idx\n  audio_cache · progress\n  scrape_jobs · app_users\n  ranking · library\n  comments"
  }

  valkey: Valkey {
    shape: cylinder
    label: "Valkey :6379\npresign URL cache (TTL ~55 min)\nAsynq job queue (runner tasks)"
  }

  meilisearch: Meilisearch {
    shape: cylinder
    label: "Meilisearch :7700\nindex: books\n(filterable: status · genres\n sortable: rank · rating\n  total_chapters · meta_updated)"
  }
}

# ─── Application — prod VPS (165.22.70.138) ───────────────────────────────────

app: Application — prod (165.22.70.138) {
  style.fill: "#eef3ff"

  caddy: caddy {
    shape: rectangle
    label: "Caddy :443 / :80 / :6380\ncustom build\n+ caddy-l4 (Redis TCP proxy)\n+ caddy-ratelimit\nauto-HTTPS · security headers\nrate limiting (per-IP)\nstatic error pages (404/502/503/504)\nCrowdSec bouncer"
  }

  backend: backend {
    shape: rectangle
    label: "Backend API :8080\n(Go)\nHTTP API server\nffmpeg (audio sample conv.)\nOpenTelemetry tracing\nSentry / GlitchTip errors"
  }

  ui: ui {
    shape: rectangle
    label: "SvelteKit UI :3000\n(adapter-node)\nSSR · session auth\nserver-side API proxy"
  }

  crowdsec: CrowdSec {
    shape: rectangle
    label: "CrowdSec :8080\nsecurity engine\nreads Caddy JSON logs\nbouncer integrated in Caddy"
  }

  dozzle: Dozzle agent {
    shape: rectangle
    label: "Dozzle agent\n127.0.0.1:7007\nlog relay → homelab dashboard"
  }
}

# ─── Runner — homelab (192.168.0.109) ────────────────────────────────────────

homelab: Runner — homelab (192.168.0.109) {
  style.fill: "#fef9ec"

  runner: runner {
    shape: rectangle
    label: "Runner :9091\n(Go background worker)\nscrape pipeline\nTTS audio job queue\nPrometheus /metrics\ncron: catalogue refresh\nAsynq worker → Valkey"
  }
}

# ─── Ops ──────────────────────────────────────────────────────────────────────

ops: Ops {
  style.fill: "#f5f5f5"

  watchtower: Watchtower {
    shape: rectangle
    label: "Watchtower\n(containrrr/watchtower)\npolls Docker Hub every 5 min\nautopulls + redeploys:\n  backend · ui\n(runner: label-disabled on prod)"
  }
}

# ─── CI / CD ──────────────────────────────────────────────────────────────────

cicd: CI / CD {
  style.fill: "#f0f9ff"

  gitea: Gitea Actions {
    shape: rectangle
    label: "Gitea Actions\n(homelab runner)\ntag v* trigger:\n  test-backend\n  check-ui (type-check + build)\n  docker-backend\n  docker-runner\n  docker-ui (bakes releases.json)\n  docker-caddy\n  → push Docker Hub\n  → Gitea Release"
  }
}

# ─── Init → Storage ───────────────────────────────────────────────────────────

init.minio-init -> storage.minio: create buckets {style.stroke-dash: 4}
init.pb-init -> storage.pocketbase: bootstrap schema {style.stroke-dash: 4}

# ─── App internal ─────────────────────────────────────────────────────────────

app.caddy -> app.ui: "/* (catch-all)\nSvelteKit — auth enforced"
app.caddy -> app.backend: "/health  /scrape*\n/api/browse  /api/catalogue\n/api/ranking  /api/version\n/api/book-preview/*\n/api/chapter-text/*\n/api/chapter-markdown/*\n/api/reindex/*  /api/cover/*\n/api/audio-proxy/*  /api/voices\n/api/audio*  /api/presign/*"
app.caddy -> storage.minio: "/avatars/*  /audio/*\n/chapters/*\n(presigned GETs)"
app.caddy -> app.crowdsec: bouncer check (15 s poll)
app.caddy -> letsencrypt: ACME cert (TLS-ALPN-01)

app.ui -> app.backend: "internal REST proxy\n(server-side only)"
app.ui -> storage.pocketbase: "auth · sessions\nprogress · library\ncomments"

app.backend -> storage.minio: "chapter objs · audio MP3s\navatars · browse cache"
app.backend -> storage.pocketbase: "books · scrape_jobs\naudio_cache · ranking"
app.backend -> storage.valkey: "presign URL cache\n(SET/GET TTL ~55 min)"
app.backend -> storage.meilisearch: "catalogue search\nfacets: genres · status"
app.backend -> pockettts: "voice sample gen.\n(on-demand · ffmpeg conv.)"

# ─── Runner → deps ────────────────────────────────────────────────────────────

homelab.runner -> novelfire: "HTTP scrape\nHTML → Markdown"
homelab.runner -> kokoro: "TTS generation\ntext → MP3"
homelab.runner -> storage.minio: "write chapters\n& audio MP3s"
homelab.runner -> storage.pocketbase: "read/update scrape_jobs\nwrite book records"
homelab.runner -> storage.meilisearch: "index books\n(on scrape completion)"
homelab.runner -> storage.valkey: "Asynq job queue\n(task consume)"

# ─── Client ───────────────────────────────────────────────────────────────────

browser -> app.caddy: HTTPS :443\n(single entry point)

# ─── Ops / CI ─────────────────────────────────────────────────────────────────

ops.watchtower -> app.backend: watch (label-enabled)
ops.watchtower -> app.ui: watch (label-enabled)
cicd.gitea -> ops.watchtower: push to Docker Hub\n→ Watchtower detects new tag
