docs: add AGENTS.md per module and unify ports to 70XX

- Add CLAUDE.md at root and AGENTS.md in apps/api, apps/indexer,
  apps/backoffice, crates/parsers with module-specific guidelines
- Unify all service ports to 70XX (no more internal/external split):
  API 7080, Indexer 7081, Backoffice 7082
- Update docker-compose.yml, Dockerfiles, config.rs defaults,
  .env.example, backoffice routes, bench.sh, smoke.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 13:57:39 +01:00
parent 131c50b1a1
commit 0f5094575a
29 changed files with 441 additions and 74 deletions

73
apps/api/AGENTS.md Normal file
View File

@@ -0,0 +1,73 @@
# apps/api — REST API (axum)
Service HTTP sur le port **7080**. Voir `AGENTS.md` racine pour les conventions globales.
## Structure des fichiers
| Fichier | Rôle |
|---------|------|
| `main.rs` | Routes, initialisation AppState, Semaphore concurrent_renders |
| `state.rs` | `AppState` (pool, caches, métriques), `load_concurrent_renders` |
| `auth.rs` | Middlewares `require_admin` / `require_read`, authentification tokens |
| `error.rs` | `ApiError` avec constructeurs `bad_request`, `not_found`, `internal`, etc. |
| `books.rs` | CRUD livres, thumbnails |
| `pages.rs` | Rendu page + double cache (mémoire LRU + disque) |
| `libraries.rs` | CRUD bibliothèques, déclenchement scans |
| `index_jobs.rs` | Suivi jobs, SSE streaming progression |
| `thumbnails.rs` | Rebuild/regénération thumbnails |
| `tokens.rs` | Gestion tokens API (create/revoke) |
| `settings.rs` | Paramètres applicatifs (stockés en DB, clé `limits`) |
| `openapi.rs` | Doc OpenAPI via utoipa, accessible sur `/swagger-ui` |
## Patterns clés
### Handler type
```rust
async fn my_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<MyDto>, ApiError> {
// ...
}
```
### Erreurs API
```rust
// Constructeurs disponibles dans error.rs
ApiError::bad_request("message")
ApiError::not_found("resource not found")
ApiError::internal("unexpected error")
ApiError::unauthorized("missing token")
ApiError::forbidden("admin required")
// Conversion auto depuis sqlx::Error et std::io::Error
```
### Authentification
- **Bootstrap token** : comparaison directe (`API_BOOTSTRAP_TOKEN`), scope Admin
- **Tokens DB** : format `stl_<prefix>_<secret>`, hash argon2 en DB, scope `admin` ou `read`
- Middleware `require_admin` → routes admin ; `require_read` → routes lecture
### OpenAPI (utoipa)
```rust
#[utoipa::path(get, path = "/books/{id}", ...)]
async fn get_book(...) { }
// Ajouter le handler dans openapi.rs (ApiDoc)
```
### Cache pages (`pages.rs`)
- **Cache mémoire** : LRU 512 entrées (`AppState.page_cache`)
- **Cache disque** : `IMAGE_CACHE_DIR` (défaut `/tmp/stripstream-image-cache`), clé SHA256
- Concurrence limitée par `AppState.page_render_limit` (Semaphore, configurable en DB)
- `spawn_blocking` pour le rendu image (CPU-bound)
### Paramètre concurrent_renders
Stocké en DB : `SELECT value FROM app_settings WHERE key = 'limits'` → JSON `{"concurrent_renders": N}`.
Chargé au démarrage dans `load_concurrent_renders`.
## Gotchas
- **LIBRARIES_ROOT_PATH** : les `abs_path` en DB commencent par `/libraries/`. Appeler `remap_libraries_path()` avant tout accès fichier.
- **Rate limit lecture** : middleware `read_rate_limit` sur les routes read (100 req/5s par défaut).
- **Métriques** : `/metrics` expose `requests_total`, `page_cache_hits`, `page_cache_misses` (atomics dans `AppState.metrics`).
- **Swagger** : accessible sur `/swagger-ui`, spec JSON sur `/openapi.json`.

View File

@@ -26,5 +26,5 @@ RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8
ENV LC_ALL=en_US.UTF-8
COPY --from=builder /app/target/release/api /usr/local/bin/api
EXPOSE 8080
EXPOSE 7080
CMD ["/usr/local/bin/api"]

View File

@@ -1,4 +1,4 @@
API_BASE_URL=http://localhost:8080
API_BASE_URL=http://localhost:7080
API_BOOTSTRAP_TOKEN=stripstream-dev-bootstrap-token
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
NEXT_PUBLIC_API_BASE_URL=http://localhost:7080
NEXT_PUBLIC_API_BOOTSTRAP_TOKEN=stripstream-dev-bootstrap-token

66
apps/backoffice/AGENTS.md Normal file
View File

@@ -0,0 +1,66 @@
# apps/backoffice — Interface d'administration (Next.js)
App Next.js 16 avec React 19, Tailwind CSS v4, TypeScript. Port de dev : **7082** (`npm run dev`).
## Structure
```
app/
├── layout.tsx # Layout global (nav sticky glassmorphism, ThemeProvider)
├── page.tsx # Dashboard
├── books/ # Liste et détail des livres
├── libraries/ # Gestion bibliothèques
├── jobs/ # Monitoring jobs
├── tokens/ # Tokens API
├── settings/ # Paramètres
├── components/ # Composants métier
│ ├── ui/ # Composants génériques (Button, Card, Badge, Icon, Input, ProgressBar, StatBox...)
│ ├── BookCard.tsx
│ ├── JobProgress.tsx
│ ├── JobsList.tsx
│ ├── LibraryForm.tsx
│ ├── FolderBrowser.tsx / FolderPicker.tsx
│ └── ...
└── globals.css # Variables CSS, Tailwind base
lib/
└── api.ts # Client API : types DTO + fonctions fetch vers l'API Rust
```
## Client API (lib/api.ts)
Tous les appels vers l'API Rust passent par `lib/api.ts`. Les types DTO sont définis là :
- `LibraryDto`, `IndexJobDto`, `BookDto`, `TokenDto`, `FolderItem`
Ajouter les nouveaux endpoints et types dans ce fichier.
## Composants UI
Les composants génériques sont dans `app/components/ui/`. Utiliser ces composants plutôt que des éléments HTML bruts :
```tsx
import { Button, Card, Badge, Icon, Input, ProgressBar, StatBox } from "@/app/components/ui";
```
## Conventions
- **App Router** : toutes les pages sont des Server Components par défaut. Utiliser `"use client"` seulement pour l'interactivité.
- **Tailwind v4** : config dans `postcss.config.js` + `tailwind.config.js`. Variables CSS dans `globals.css`.
- **Thème** : `ThemeProvider` + `ThemeToggle` pour dark/light mode via `next-themes`.
- **Icônes** : composant `<Icon name="..." size="sm|md|lg" />` dans `ui/Icon.tsx` — pas de librairie externe.
- **Navigation** : routes typées dans `layout.tsx` (`"/" | "/books" | "/libraries" | "/jobs" | "/tokens" | "/settings"`).
## Commandes
```bash
npm install
npm run dev # http://localhost:7082
npm run build
npm run start # Production sur http://localhost:7082
```
## Gotchas
- **Port 7082** : pas le port Next.js par défaut (3000). Défini dans `package.json` scripts (`-p 7082`).
- **API_BASE_URL** : en prod, configuré via env. En dev local, l'API doit tourner sur `http://localhost:7080`.
- **React 19 + Next.js 16** : utiliser les nouvelles APIs (actions serveur, `use()` hook) si disponibles.
- **Pas de gestion d'état global** : fetch direct depuis les Server Components ou `useState`/`useEffect` dans les Client Components.

View File

@@ -12,11 +12,11 @@ RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8082
ENV PORT=7082
ENV HOST=0.0.0.0
RUN apk add --no-cache wget
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 8082
EXPOSE 7082
CMD ["node", "server.js"]

View File

@@ -13,7 +13,7 @@ export async function GET(
const quality = searchParams.get("quality") || "";
// Construire l'URL vers l'API backend
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
const apiBaseUrl = process.env.API_BASE_URL || "http://api:7080";
const apiUrl = new URL(`${apiBaseUrl}/books/${bookId}/pages/${pageNum}`);
apiUrl.searchParams.set("format", format);
if (width) apiUrl.searchParams.set("width", width);

View File

@@ -6,7 +6,7 @@ export async function GET(
) {
const { bookId } = await params;
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
const apiBaseUrl = process.env.API_BASE_URL || "http://api:7080";
const apiUrl = `${apiBaseUrl}/books/${bookId}/thumbnail`;
const token = process.env.API_BOOTSTRAP_TOKEN;

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
const apiBaseUrl = process.env.API_BASE_URL || "http://api:7080";
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
if (!apiToken) {

View File

@@ -5,7 +5,7 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
const apiBaseUrl = process.env.API_BASE_URL || "http://api:7080";
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
if (!apiToken) {

View File

@@ -5,7 +5,7 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
const apiBaseUrl = process.env.API_BASE_URL || "http://api:7080";
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
if (!apiToken) {

View File

@@ -5,7 +5,7 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
const apiBaseUrl = process.env.API_BASE_URL || "http://api:7080";
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
if (!apiToken) {

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
const apiBaseUrl = process.env.API_BASE_URL || "http://api:7080";
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
if (!apiToken) {

View File

@@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
const apiBaseUrl = process.env.API_BASE_URL || "http://api:7080";
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
if (!apiToken) {

View File

@@ -6,7 +6,7 @@ export async function GET(
) {
try {
const { key } = await params;
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
const baseUrl = process.env.API_BASE_URL || "http://api:7080";
const token = process.env.API_BOOTSTRAP_TOKEN;
const response = await fetch(`${baseUrl}/settings/${key}`, {
@@ -33,7 +33,7 @@ export async function POST(
) {
try {
const { key } = await params;
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
const baseUrl = process.env.API_BASE_URL || "http://api:7080";
const token = process.env.API_BOOTSTRAP_TOKEN;
const body = await request.json();

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
const baseUrl = process.env.API_BASE_URL || "http://api:7080";
const token = process.env.API_BOOTSTRAP_TOKEN;
const response = await fetch(`${baseUrl}/settings/cache/clear`, {

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
try {
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
const baseUrl = process.env.API_BASE_URL || "http://api:7080";
const token = process.env.API_BOOTSTRAP_TOKEN;
const response = await fetch(`${baseUrl}/settings/cache/stats`, {

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
try {
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
const baseUrl = process.env.API_BASE_URL || "http://api:7080";
const token = process.env.API_BOOTSTRAP_TOKEN;
const response = await fetch(`${baseUrl}/settings`, {

View File

@@ -90,7 +90,7 @@ export type SeriesDto = {
};
function config() {
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
const baseUrl = process.env.API_BASE_URL || "http://api:7080";
const token = process.env.API_BOOTSTRAP_TOKEN;
if (!token) {
throw new Error("API_BOOTSTRAP_TOKEN is required for backoffice");

75
apps/indexer/AGENTS.md Normal file
View File

@@ -0,0 +1,75 @@
# apps/indexer — Service d'indexation
Service background sur le port **7081**. Voir `AGENTS.md` racine pour les conventions globales.
## Structure des fichiers
| Fichier | Rôle |
|---------|------|
| `main.rs` | Point d'entrée, initialisation, lancement du worker |
| `lib.rs` | `AppState` (pool, meili, api_base_url) |
| `worker.rs` | Boucle principale : claim job → process → cleanup stale |
| `job.rs` | `claim_next_job`, `process_job`, `fail_job`, `cleanup_stale_jobs` |
| `scanner.rs` | Scan filesystem, parsing parallèle (rayon), batching DB |
| `batch.rs` | `flush_all_batches` avec UNNEST, structures `BookInsert/Update/FileInsert/Update/ErrorInsert` |
| `scheduler.rs` | Auto-scan : vérifie toutes les 60s les bibliothèques à monitorer |
| `watcher.rs` | File watcher temps réel |
| `meili.rs` | Indexation/sync Meilisearch |
| `api.rs` | Appels HTTP vers l'API (pour checkup thumbnails) |
| `utils.rs` | `remap_libraries_path`, `unmap_libraries_path`, `compute_fingerprint`, `kind_from_format` |
## Cycle de vie d'un job
```
claim_next_job (UPDATE ... RETURNING, status pending→running)
└─ process_job
├─ scanner::scan_library (rayon par_iter pour le parsing)
│ └─ flush_all_batches toutes les BATCH_SIZE=100 itérations
└─ meili sync
└─ api checkup thumbnails (POST /index/jobs/:id/thumbnails/checkup)
```
- Annulation : `is_job_cancelled` vérifié toutes les 10 fichiers ou 1s — retourne `Err("Job cancelled")`
- Jobs stale (running au redémarrage) → nettoyés par `cleanup_stale_jobs` au boot
## Pattern batch (batch.rs)
Toutes les opérations DB massives passent par `flush_all_batches` avec UNNEST :
```rust
// Accumuler dans des Vec<BookInsert>, Vec<FileInsert>, etc.
books_to_insert.push(BookInsert { ... });
// Flush quand plein ou en fin de scan
if books_to_insert.len() >= BATCH_SIZE {
flush_all_batches(&pool, &mut books_update, &mut files_update,
&mut books_insert, &mut files_insert, &mut errors_insert).await?;
}
```
Toutes les opérations du flush sont dans une seule transaction.
## Scan filesystem (scanner.rs)
Pipeline en 3 étapes :
1. **Collect** : WalkDir → filtrer par format (CBZ/CBR/PDF)
2. **Parse** : `file_infos.into_par_iter().map(parse_metadata)` (rayon)
3. **Process** : séquentiel pour les inserts/updates DB
Fingerprint = SHA256(taille + mtime) pour détecter les changements sans relire le fichier.
## Path remapping
```rust
// abs_path en DB = chemin conteneur (/libraries/...)
// Sur l'hôte : LIBRARIES_ROOT_PATH remplace /libraries
utils::remap_libraries_path(&abs_path) // DB → filesystem local
utils::unmap_libraries_path(&local_path) // filesystem local → DB
```
## Gotchas
- **Thumbnails** : générés par l'API après handoff, pas par l'indexer directement. L'indexer appelle `/index/jobs/:id/thumbnails/checkup` via `api.rs`.
- **full_rebuild** : si `true`, ignore les fingerprints → tous les fichiers sont retraités.
- **Annulation** : vérifier `is_job_cancelled` régulièrement pour respecter les annulations utilisateur.
- **Watcher + scheduler** : tournent en tâches tokio séparées dans `worker.rs`, en parallèle de la boucle principale.

View File

@@ -23,5 +23,5 @@ RUN --mount=type=cache,target=/sccache \
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget unrar-free && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/indexer /usr/local/bin/indexer
EXPOSE 8081
EXPOSE 7081
CMD ["/usr/local/bin/indexer"]