- 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
349 lines
13 KiB
Markdown
349 lines
13 KiB
Markdown
# 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
|
|
```
|