docs: agy.md — architecture, routes, bugfix, and pending plan

- Fix poller shape mismatch (res.data -> res.data?.data) that caused
  silent TypeError and 0 posts despite completed galleries (#1)
- Align types in api.ts to reflect ApiResponse<PaginatedResponse<>> shape
- Comprehensive agy.md documenting architecture, routes, 43 CBZ galleries,
  known bugs, blocker (API listing), and implementation plan
This commit is contained in:
Renato
2026-07-24 00:50:15 +02:00
parent 4e9f815a53
commit 269762557e
3 changed files with 351 additions and 3 deletions
+348
View File
@@ -0,0 +1,348 @@
# worst-scan web — AGY (Arquitectura, Galerías, Yapa)
> Generado: 2026-07-23 — Post-mortem del setup inicial + deuda técnica
---
## Stack
| Capa | Tecnología |
|-----------|------------|
| Framework | Next.js 16.2.11 (Turbopack, standalone output) |
| Lenguaje | TypeScript 5 |
| UI | Tailwind 4 + Lucide icons |
| DB | SQLite via better-sqlite3 (WAL mode) |
| API client | fetch plano (sin axios/fetch wrapper pesado) |
| CBZ parsing | JSZip (vía `src/lib/cbz.ts`) |
| Auth | JWT via jose (cookie `session`) |
| Deploy | Docker (Node 22-alpine, multi-stage build) |
| Proxy reverso | Caddy (dominio `manga.cbcren.online`) |
---
## Arquitectura
```
manga.cbcren.online (Caddy)
└─ worst-scan-web:3000 (Docker container, red caddy)
├─ API_BASE_URL=http://host.docker.internal:8080/api/v1
│ └─ Pipeline API (kindle_final, FastAPI en :8080)
└─ /app/data/ (volumen persistente worst-scan-web-data)
├─ worst-scan.db (SQLite — posts, estados)
├─ covers/ (portadas cacheadas)
└─ galleries/ (páginas extraídas + MOBIs — futuro)
```
### Conexiones
| Origen | Destino | Método |
|--------|---------|--------|
| worst-scan-web | `host.docker.internal:8080` | HTTP (pipeline API) |
| worst-scan-web | `--add-host host.docker.internal:host-gateway` | Docker |
---
## Route map (público)
| Ruta | Tipo | Descripción | Status |
|------|------|-------------|--------|
| `/` | Página | Home — grid de posts | ✅ |
| `/p/[slug]` | Página | Detalle de post (cover, tags, botones) | ✅ |
| `/gallery/[gid]/read` | Página | Lector de páginas (full-screen) | 🔧 mover a grupo `(reader)/` |
| `/tag/[tag]` | Página | Filtro por tag | ✅ |
| `/login` | Página | Login con password | ✅ |
| `/feed` | Página | Admin feed (grupo `(app)`, con sidebar) | ✅ |
| `/admin/posts` | Página | Admin posts | ✅ |
| `/queue` | Página | Cola del pipeline | ✅ |
| `/search` | Página | Buscar en nhentai/ehentai | ✅ |
| `/submit` | Página | Enviar URL al pipeline | ✅ |
---
## API endpoints (propios de la web)
| Ruta | Método | Función | Status |
|------|--------|---------|--------|
| `/api/auth/login` | POST | Login con WEB_PASSWORD | ✅ |
| `/api/auth/logout` | POST | Logout | ✅ |
| `/api/cover/[gid]` | GET | Portada (caché local → proxy → 404) | ✅ + fallback mount en plan |
| `/api/pages/[gid]` | GET | **Nuevo** — conteo de páginas extraídas | 🔧 plan |
| `/api/pages/[gid]/[n]` | GET | **Nuevo** — stream de página N | 🔧 plan |
| `/api/download/[gid]/mobi` | GET | **Nuevo** — descarga MOBI guardado | 🔧 plan |
| `/api/cron/poll` | GET | Trigger manual del poller | ✅ |
| `/api/posts` | GET | Listar posts (público) | ✅ |
| `/api/posts/[id]` | GET/PUT/DELETE | CRUD posts admin | ✅ |
| `/api/posts/[id]/publish` | POST | Publicar/despublicar | ✅ |
| `/api/proxy/[...path]` | ALL | Proxy inverso al pipeline API | ✅ |
---
## Pipeline API endpoints (consumidos vía proxy)
Los que **funcionan** y la web ya usa o va a usar:
| Ruta | Status | Uso |
|------|--------|-----|
| `GET /api/v1/health` | ✅ 200 | Health check |
| `GET /api/v1/status` | ✅ 200 | Estado del pipeline |
| `GET /api/v1/galleries?status=` | ✅ 200 | Listar galerías (SOLO 6 trackeadas) |
| `GET /api/v1/galleries/{gid}/summary` | ✅ 200 | Metadata de galería (funciona para cualquier gid) |
| `GET /api/v1/galleries/{gid}/artifacts` | ✅ 200 | Archivos generados (paths, no bytes) |
| `GET /api/v1/galleries/{gid}/cover` | ✅ 200/404 | Página 1 (portada) |
| `GET /api/v1/galleries/{gid}/download/cbz` | ✅ **200** | **CBZ traducido** — 43 galerías (nuevo) |
| `GET /api/v1/galleries/{gid}/download/mobi` | ✅ 200/404 | MOBI descargable |
| `GET /api/v1/galleries` | POST | Enviar URL para procesar |
| `POST /api/v1/search` | ✅ 200 | Buscar en nhentai/ehentai |
| `POST /api/v1/search/process` | ✅ 200 | Buscar + auto-encolar |
---
## Galerías — estado real
| Métrica | Valor |
|---------|-------|
| Galerías en `output/artifacts/` | **69** |
| Con `rendered/` (traducidas) | **44** |
| Con CBZ traducido (post-backfill) | **43** únicas / **46** archivos |
| 3 con CBZ duplicado (named + `translated.cbz`) | 666886, 666887, 666888 |
| 1 con rendered pero sin CBZ | 615512 (4 páginas) |
| API lista (via `/galleries`) | **6** (5 completed + 1 failed) |
| Web tiene como posts | **5** |
| **Invisibles para la web** | **~38** |
---
## Bugs conocidos (web)
### 1. Shape mismatch poller → no crea posts (FIXED 2026-07-23)
**Causa:** La API devuelve `ApiResponse<PaginatedResponse<>>` (doble `data`), pero `poller.ts:28` accedía `res.data` esperando un array. `res.data` era el objeto `{data:[], meta:{}}`. `for...of` lanzaba TypeError, el `catch{}` vacío lo tragaba silenciosamente.
**Fix:** `galleries = res.data?.data || res.data || []`
**Archivos:** `src/lib/poller.ts:28`, `src/lib/api.ts:55-59,126-130` (tipos)
### 2. Portada 404 para galería 665602
**Causa:** La galería 665602 ("Inakax [Spanish]") es Spanish (skip translate). El endpoint `/cover` del pipeline 404a porque no tiene `images/` ni `originals/` — solo `rendered/`. El cover route no tiene fallback.
**Fix pendiente:** Fallback a `rendered/001.jpg` del CBZ extraído (o mount). Se resuelve cuando el poller extraiga las páginas del CBZ y la cover pueda servirse de `pages/001.jpg`.
### 3. Reader repite portada N veces
**Causa:** `(app)/gallery/[gid]/read/page.tsx` no tiene acceso a páginas renderizadas porque el API no las sirve individualmente. El código genera N urls a `/cover` — todas página 1.
**Fix plan:** Poller descarga CBZ → JSZip extrae → guarda en local → reader sirve desde `/api/pages/[gid]/N`.
### 4. Reader está en grupo `(app)` con sidebar admin
**Problema:** Hereda el layout del admin (sidebar). No debería. Y el middleware no tiene `/gallery` como ruta pública (aunque sin WEB_PASSWORD pasa igual).
**Fix:** Mover a grupo `(reader)/` con layout bare + agregar `/gallery` y `/api/pages` a `publicPaths` en middleware.
### 5. Botón "Archivos" en detalle
**Problema:** Linkea a `/api/proxy/galleries/{gid}/artifacts` que devuelve JSON (paths, no archivos). Inútil.
**Fix:** Eliminar botón (líneas 88-96 de `src/app/(public)/p/[slug]/page.tsx`).
### 6. Fuente nhentai es texto, no link
**Problema:** `post.source` se muestra como span estático.
**Fix:** Si `source==="nhentai"``<a href="https://nhentai.net/g/{gid}/" target=_blank>` con icono externo.
### 7. Admin link en header público
**Problema:** Cualquiera ve "Admin" en el header.
**Fix:** Eliminar link de `src/app/(public)/layout.tsx`.
---
## Dependencias bloqueantes
### 🚫 BLOQUEANTE: Listado de galerías traducidas
La API solo lista 6 galerías en `/galleries` (las del UrlQueue activo). Hay ~38 galerías traducidas con CBZ que la web **no puede descubrir**. El poller descubre galerías vía listado — sin los 43 gids, el poller nunca las importa.
**Solución del lado API:** Que `/galleries` escanee `output/artifacts/` (o un glob de CBZs) y devuelva todos los gids traducidos. Opción B: endpoint `GET /galleries/translated`.
**Impacto en la web:** El poller cambia su fuente de `?status=completed` → listado completo. Nada más.
### ✅ RESUELTO: CBZ traducido
El endpoint `download/cbz` ahora sirve el CBZ con las páginas renderizadas. La web puede descargarlo con JSZip y extraer las páginas. Sin esto no hay reader posible.
**Nota:** 666886, 666887, 666888 tienen `translated.cbz` duplicado además del nombrado. El endpoint debe deduplicar o asegurar consistencia.
---
## Plan de implementación pendiente (la web)
### Paso 1: DB schema — añadir columnas de storage
```sql
ALTER TABLE posts ADD COLUMN pages_count INTEGER DEFAULT 0;
ALTER TABLE posts ADD COLUMN mobi_stored INTEGER DEFAULT 0;
ALTER TABLE posts ADD COLUMN cbz_path TEXT;
```
### Paso 2: `src/lib/gallery-store.ts` (nuevo)
- `downloadAndExtractCbz(gid)`: fetch proxy → JSZip → guardar en `/app/data/galleries/{gid}/pages/`
- `downloadMobi(gid)`: fetch proxy → guardar MOBI
- `getPagePath(gid, n)`: resolver path de página local
- `getPageCount(gid)`: contar páginas locales
- `getCoverFromPages(gid)`: primera página como cover fallback
### Paso 3: Poller reescrito
- Descubrimiento via listado completo (depende de API fix)
- Por cada nuevo gid:
1. `downloadAndExtractCbz` — si 404 → SKIP (no traducida)
2. Cachear cover desde página 1 extraída
3. `downloadMobi` — best effort
4. Crear post con `pages_count`, `mobi_stored`
### Paso 4: Endpoints nuevos
- `GET /api/pages/[gid]``{count, available}`
- `GET /api/pages/[gid]/[n]` → stream imagen N
- `GET /api/download/[gid]/mobi` → stream MOBI
### Paso 5: Reader rewrite
- Mover a `(reader)/gallery/[gid]/read` con layout bare
- Fetch páginas de `/api/pages/[gid]/N`
### Paso 6: UI fixes (5 items)
- Sacar "Archivos", agregar "Descargar MOBI"
- Link nhentai externo
- "Leer" solo si `pages_count > 0`
- Sacar "Admin" del header
- Middleware: abrir `/gallery`, `/api/pages`, `/api/download`
### Paso 7: Cover fallback
- Si proxy 404a y hay páginas locales → servir `pages/001.jpg`
---
## Container
```ini
# Docker run actual (sin mount)
docker run -d \
--name worst-scan-web \
--network caddy \
--add-host host.docker.internal:host-gateway \
-v worst-scan-web-data:/app/data \
-e API_BASE_URL=http://host.docker.internal:8080/api/v1 \
-e WEB_PASSWORD= \
-e PORT=3000 \
-e DB_PATH=/app/data/worst-scan.db \
-e COVERS_DIR=/app/data/covers \
worst_web-web:latest
# Rebuild
docker compose build
docker stop worst-scan-web && docker rm worst-scan-web
# luego el docker run de arriba
```
---
## Variables de entorno
| Variable | Default | Descripción |
|----------|---------|-------------|
| `API_BASE_URL` | `http://127.0.0.1:8080/api/v1` | URL de la API del pipeline |
| `API_KEY` | `""` | API key (vacío = sin auth) |
| `WEB_PASSWORD` | `""` | Password admin (vacío = sin auth) |
| `PORT` | `3000` | Puerto del servidor |
| `DB_PATH` | `data/worst-scan.db` | Ruta a SQLite |
| `COVERS_DIR` | `data/covers` | Directorio de portadas cacheadas |
| `ARTIFACTS_DIR` | `data/galleries` | Directorio de páginas extraídas (futuro) |
---
## Estructura de archivos relevante
```
src/
├── app/
│ ├── (app)/ # Admin group (sidebar)
│ │ ├── admin/posts/ # Admin posts CRUD
│ │ ├── feed/ # Admin feed page
│ │ ├── gallery/[gid]/read/ # READER (mover a (reader)/)
│ │ ├── queue/ # Queue management
│ │ ├── search/ # Search nhentai/ehentai
│ │ ├── submit/ # Submit URL form
│ │ └── layout.tsx # Sidebar layout
│ ├── (public)/ # Public group (header+footer)
│ │ ├── p/[slug]/ # Post detail page
│ │ ├── tag/[tag]/ # Tag filter page
│ │ ├── layout.tsx # Public header + footer
│ │ └── page.tsx # Home page (grid)
│ ├── api/
│ │ ├── auth/ # Login/logout
│ │ ├── cover/[gid]/ # Cover serve
│ │ ├── cron/poll/ # Poller trigger
│ │ ├── pages/ # (FUTURO) page serve
│ │ ├── posts/ # Posts CRUD
│ │ └── proxy/[...path]/ # Proxy inverso al pipeline
│ ├── favicon.ico
│ ├── globals.css
│ └── layout.tsx # Root layout
├── components/
│ ├── cover-image.tsx
│ ├── empty-state.tsx
│ ├── filter-bar.tsx
│ ├── gallery-card.tsx
│ ├── gallery-grid.tsx
│ ├── layout/sidebar.tsx
│ ├── post-card.tsx
│ ├── search-form.tsx
│ ├── submit-form.tsx
│ └── tag-badge.tsx
├── lib/
│ ├── api.ts # API client (fetch wrapper)
│ ├── auth.ts # JWT session
│ ├── cbz.ts # JSZip CBZ loader (LISTO, esperando uso)
│ ├── cover-cache.ts # Cover caching
│ ├── db.ts # SQLite schema + queries
│ ├── poller.ts # Poller (BUG FIXED, necesita rewrite)
│ ├── slug.ts # URL slug helper
│ ├── types.ts # TypeScript interfaces
│ └── utils.ts # formatDate, etc
├── hooks/
├── instrumentation.ts # Poller startup
└── middleware.ts # Auth middleware
```
---
## Comandos útiles
```bash
# Logs del container
docker logs -f worst-scan-web
# Rebuild + redeploy
docker compose build && docker stop worst-scan-web && docker rm worst-scan-web
docker run -d --name worst-scan-web --network caddy \
--add-host host.docker.internal:host-gateway \
-v worst-scan-web-data:/app/data \
-e API_BASE_URL=http://host.docker.internal:8080/api/v1 \
worst_web-web:latest
# Verificar DB interna
docker exec worst-scan-web sh -c 'node -e "const D=require(\"better-sqlite3\"); const db=new D(process.env.DB_PATH); console.log(db.prepare(\"SELECT COUNT(*) FROM posts\").get())"'
# Curl a la API del pipeline desde host
curl -s http://127.0.0.1:8080/api/v1/health
curl -s http://127.0.0.1:8080/api/v1/galleries/666408/download/cbz -o /tmp/test.cbz
# Recargar Caddy
docker exec caddy-ingress caddy reload --config /etc/caddy/Caddyfile
# Forzar ciclo de poller
curl -s https://manga.cbcren.online/api/cron/poll
```
+2 -2
View File
@@ -55,7 +55,7 @@ export const api = {
list: (status?: string, page = 1, perPage = 20) => {
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
if (status) params.set("status", status)
return fetchApi<PaginatedResponse<GalleryItem>>(`/galleries?${params}`)
return fetchApi<ApiResponse<PaginatedResponse<GalleryItem>>>(`/galleries?${params}`)
},
get: (gid: string) =>
@@ -126,7 +126,7 @@ export const api = {
list: (status?: string, page = 1, perPage = 20) => {
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
if (status) params.set("status", status)
return fetchApi<PaginatedResponse<QueueItem>>(`/queue?${params}`)
return fetchApi<ApiResponse<PaginatedResponse<QueueItem>>>(`/queue?${params}`)
},
stats: () =>
+1 -1
View File
@@ -25,7 +25,7 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
try {
const res = await api.galleries.list("completed", 1, 100)
const galleries = res.data || []
const galleries = res.data?.data || res.data || []
for (const g of galleries) {
try {