- 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
13 KiB
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
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 MOBIgetPagePath(gid, n): resolver path de página localgetPageCount(gid): contar páginas localesgetCoverFromPages(gid): primera página como cover fallback
Paso 3: Poller reescrito
- Descubrimiento via listado completo (depende de API fix)
- Por cada nuevo gid:
downloadAndExtractCbz— si 404 → SKIP (no traducida)- Cachear cover desde página 1 extraída
downloadMobi— best effort- Crear post con
pages_count,mobi_stored
Paso 4: Endpoints nuevos
GET /api/pages/[gid]→{count, available}GET /api/pages/[gid]/[n]→ stream imagen NGET /api/download/[gid]/mobi→ stream MOBI
Paso 5: Reader rewrite
- Mover a
(reader)/gallery/[gid]/readcon 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
# 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
# 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