# 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>` (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"` → `` 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 ```