diff --git a/AGENTS.md b/AGENTS.md index 1ec1942..2043c09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,922 +1,363 @@ -# worstscan — Manga Translation Pipeline +# worst-scan-web — Portal Web del Fansub (Next.js 16) -Pipeline de traducción automática de manga (JP/EN/KR/CN → ES) con bot de Telegram, API REST, y delivery a Kindle vía Nextcloud. Descarga de nhentai/e-hentai, traduce con LLM via proxy OpenAI-compatible, inpinta globos con modelos de visión, renderiza texto, empaqueta en MOBI con KCC y sube a Nextcloud. +Portal web público + panel admin del fansub de manga **worst-scan** (escaneado y traducción automática JP/EN/KR/CN → ES). Este repo es SOLO el frontend web (Next.js). El pipeline de traducción (descarga, OCR, LLM, inpainting, render, MOBI, bot de Telegram) vive en un repo aparte y expone una API REST FastAPI en `:8080` (`API_BASE_URL`), que esta web consume vía proxy. + +**Dominio de producción:** `manga.cbcren.online` (vía Caddy con auto-HTTPS) +**Doc de arquitectura original:** ver `agy.md` (AGY = Arquitectura, Galerías, Yapa) + +--- + +## Stack + +| Capa | Tecnología | +|------|-----------| +| Framework | Next.js **16.2.11** (App Router, Turbopack, `output: standalone`) | +| UI | React **19.2.4**, Tailwind CSS **4**, iconos lucide-react | +| Datos | SQLite vía `better-sqlite3` ^13 (modo WAL), SWR ^2.4.2 en cliente | +| Auth | JWT HS256 vía `jose` ^6 (cookie `session`, expiración 7d) | +| Utilidades | JSZip ^3 (parseo CBZ en cliente), TypeScript 5 | +| Deploy | Docker (multi-stage Node 22-alpine) o systemd + Node directo; Caddy como reverse proxy | --- ## Arquitectura General ``` - ┌──────────────────────────────────────────────────────┐ - │ Entry Points │ - │ bot_artifacts.py │ artifacts.py │ cli.py │ - └────────┬──────────┴───────┬──────────┴───────┬────────┘ - │ │ │ - ┌────────▼──────────────────▼──────────────────▼────────┐ - │ Queues & State │ - │ UrlQueue (JSON) GalleryQueue (JSON) StateDB │ - │ data/bot_queue.json data/modal_queue/ SQLite │ - └────────────────────────┬──────────────────────────────┘ - │ - ┌────────────────────────▼──────────────────────────────┐ - │ Translation Pipeline │ - │ ┌──────┐ ┌──────────┐ ┌──────┐ ┌────────┐ ┐ │ - │ │DL │→ │Preprocess│→ │Refine│→ │Inpaint │→ │ │ - │ │.cbz │ │detect/ │ │LLM │ │AOT/ │ │ │ - │ │ │ │OCR/trans │ │polish│ │lama │ │ │ - │ └──────┘ └──────────┘ └──────┘ └────────┘ │ │ - │ ┌────────────────────────┐ ┌──────────┐ ┐ │ │ - │ │Complete (V14 Render) │→ │KCC→MOBI │→ │ │ │ - │ │PIL+textbbox typesetting│ │KPW34 │ │ │ │ - │ └────────────────────────┘ └──────────┘ ┘ │ │ - └────────────────────────┬──────────────────────────────┘ - │ - ┌────────────────────────▼──────────────────────────────┐ - │ Delivery │ - │ Nextcloud (sudo cp + occ files:scan) │ - │ Local (shutil.copy2 to output/) │ - └────────────────────────┬──────────────────────────────┘ - │ - ┌────────────────────────▼──────────────────────────────┐ - │ API REST (FastAPI) │ - │ GET /health | /status | POST /galleries | /search │ - │ /summary | /cover | X-API-Key auth. Puerto 8080. │ - │ Swagger en /docs. Puerto 8080. │ - └───────────────────────────────────────────────────────┘ + ┌─────────────────────────────────────┐ + │ Caddy (manga.cbcren.online) │ + │ auto-HTTPS + TLS │ + └──────────────┬──────────────────────┘ + │ + ┌──────────────▼──────────────────────┐ + │ worst-scan-web (Next.js) │ + │ puerto 3000 (container) │ + │ │ + │ ┌─────────────────────────────┐ │ + │ │ Sitio PÚBLICO (server-side)│ │ + │ │ / /p/[slug] /tag/[tag] │ │ + │ │ → SQLite directo (posts) │ │ + │ └─────────────────────────────┘ │ + │ ┌─────────────────────────────┐ │ + │ │ Panel PRIVADO (client-side)│ │ + │ │ /feed /queue /search │ │ + │ │ /submit /admin/posts │ │ + │ │ → SWR → /api/proxy/* │ │ + │ └─────────────────────────────┘ │ + │ ┌─────────────────────────────┐ │ + │ │ API propia: /api/auth, │ │ + │ │ /api/posts, /api/cover, │ │ + │ │ /api/cron, /api/setup, │ │ + │ │ /api/webhooks/manga, │ │ + │ │ /api/proxy/[...path] │ │ + │ └─────────────────────────────┘ │ + └──────────────┬──────────────────────┘ + │ API_BASE_URL (proxy) + ┌──────────────▼──────────────────────┐ + │ Pipeline REST (FastAPI :8080) │ + │ /api/v1/health|status|galleries │ + │ /summary|artifacts|cover|pages │ + └─────────────────────────────────────┘ ``` --- -## Entry Points (Completo) - -| File | Rol | Cómo ejecutar | Producción | -|------|-----|---------------|------------| -| **`bot_artifacts.py`** | **Bot Telegram + API REST + Feed diario** | `uv run python3 bot_artifacts.py` (systemd `bot-artifacts.service`) | ✅ | -| **`artifacts.py`** | **Pipeline completa** (download → translate → inpaint → render → MOBI) | `python artifacts.py [--skip-mobi] [--no-es-search] ` | ✅ | -| **`cli.py`** | **CLI tool** (11 subcomandos) | `python cli.py [args]` | ✅ | -| `inject_gallery.py` | Inyección directa al pipeline | `python inject_gallery.py ...` | ❌ aux | -| `process.py` | Pipeline anterior (718 lines, coexiste) | — | ❌ legacy | -| `bot.py` | Bot Telegram legacy (26KB, 749 lines) | `python -m src.bot` | ❌ legacy | -| `visual_feedback.py` | Bucle de feedback visual LLM | — | ❌ standalone | -| `_render_all.py` | Render interno variante | — | ❌ exp | -| `_render_gallery.py` | Render interno | — | ❌ exp | -| `_render_no_stroke.py` | Render sin stroke | — | ❌ exp | -| `_render_variants.py` | Variantes de render | — | ❌ exp | -| `_smart_render.py` | Smart render v1 | — | ❌ exp | -| `_smart_render_v2.py` | Smart render v2 | — | ❌ exp | -| `_smart_render_v3.py` | Smart render v3 | — | ❌ exp | -| `_vision_feedback.py` | Vision feedback v1 | — | ❌ exp | -| `_vision_feedback_v2.py` | Vision feedback v2 | — | ❌ exp | -| `scripts/batch_convert_cbz.py` | Batch KCC de CBZs en Nextcloud | Standalone | ❌ script | -| `scripts/download_type90.py` | Descarga TYPE.90 español | Standalone | ❌ script | -| `scripts/test_pipeline_653011.py` | Test end-to-end nhentai | Standalone | ❌ script | - -### systemd service -```ini -# /etc/systemd/system/bot-artifacts.service -ExecStart=/home/ren/.local/bin/uv run python3 bot_artifacts.py -Restart=always -``` -Restart: `sudo systemctl restart bot-artifacts.service` -Logs: `sudo journalctl -u bot-artifacts.service -f` - -### Import Quirk -`src/` NO está instalado como paquete. Todos los comandos se ejecutan desde la raíz del proyecto, o `PYTHONPATH=.` cuando se corre desde otro lado. - ---- - -## Config (.env vía pydantic-settings) - -Todas las settings via `src/config.py:Settings(BaseSettings)`. `.env` en la raíz del proyecto. - -### Telegram y Delivery - -| Variable | Default | Descripción | -|----------|---------|-------------| -| `telegram_bot_token` | `""` | Token del bot (obligatorio para bot_artifacts.py) | -| `telegram_chat_id` | `0` | Chat/User autorizado | -| `delivery_backend` | `"local"` | `"local"` o `"nextcloud"` | -| `output_dir` | `"output"` | Directorio de salida para delivery local | -| `nextcloud_url` | `""` | Nextcloud server URL | -| `nextcloud_user` | `""` | Nextcloud username | -| `nextcloud_pass` | `""` | Nextcloud password | -| `nextcloud_base_path` | `"/kindle"` | Nextcloud base path | -| `nextcloud_data_path` | `""` | Nextcloud data directory path | -| `state_db_path` | `"data/manga_state.db"` | SQLite DB path | - -### Translation Pipeline - -| Variable | Default | Descripción | -|----------|---------|-------------| -| `translation_enabled` | `True` | Flag para activar/desactivar traducción | -| `translation_api_key` | `""` | API key para proxy de traducción | -| `translation_api_base` | `"http://127.0.0.1:55990/v1"` | Endpoint del proxy (OpenAI-compatible) | -| `translation_model` | `"xiaomi/mimo-v2.5"` | Modelo LLM por defecto | -| `translation_target_lang` | `"ESP"` | Lengua destino | -| `translation_config_path` | `"translate_config.json"` | Config file de manga-translator | -| `minimax_api_key` | `""` | MiniMax API key (fallback de translation_api_key) | -| `minimax_api_host` | `"https://api.minimax.io"` | MiniMax host | -| `vlm_endpoint` | `"/v1/coding_plan/vlm"` | VLM endpoint | -| `minimax_chat_model` | `"MiniMax-Text-01"` | MiniMax chat model | -| `vlm_timeout` | `90` | VLM timeout en segundos | - -### Scrapers - -| Variable | Default | Descripción | -|----------|---------|-------------| -| `hentai_search_url` | `"https://e-hentai.org/..."` | URL por defecto de búsqueda e-hentai | -| `hentai_subfolder` | `"hentai"` | Subcarpeta para hentai en Nextcloud | -| `hentai_cleanup_days` | `7` | Días para cleanup | -| `hentai_run_hour` | `6` | Hora del scheduler | -| `hentai_rate_limit` | `1.5` | Rate limit en segundos | -| `priority_rate_limit` | `0.75` | Rate limit para priority | -| `ehentai_manga_run_hour` | `6` | Hora del EhentaiMangaScheduler | -| `nhentai_daily_count` | `10` | Límite diario nhentai | -| `fansub_enabled` | `True` | Activar fansub pipeline | - -### REST API - -| Variable | Default | Descripción | -|----------|---------|-------------| -| `api_enabled` | `True` | Activar servidor API REST | -| `api_host` | `"0.0.0.0"` | Bind address | -| `api_port` | `8080` | Puerto | -| `api_key` | `""` | API key (vacío = sin auth en dev) | -| `api_rate_limit_per_min` | `60` | Rate limit general req/min | -| `api_submit_limit_per_min` | `10` | Rate limit para submits/min | - -### Misc - -| Variable | Default | Descripción | -|----------|---------|-------------| -| `mangas_folder` | `"/mnt/mangas"` | Carpeta vigilada por MangaWatcher | -| `kindle_profile` | `"KPW34"` | Perfil Kindle para KCC | -| `watch_interval` | `30` | Intervalo MangaWatcher en segundos | - ---- - -## Pipeline de Traducción — Flujo de Cascada - -`bot_artifacts.py` y `artifacts.py` ejecutan el mismo pipeline de 5 fases: +## Estructura de Archivos ``` -URL (nhentai/e-hentai) - │ - ▼ -┌───────────────────────────────────────────────────────┐ -│ [1/5] Download (artifacts.download_only) │ -│ nhentai CDN (i.nhentai.net, ~0.1s/page) │ -│ o fallback: e-hentai scraper (~2s/page) │ -│ → data/work_{gid}/download_meta.json (enriquecido) │ -│ → data/work_{gid}/ CBZ extraído en images/ │ -└───────────────────────┬───────────────────────────────┘ - │ - ▼ -┌───────────────────────────────────────────────────────┐ -│ [2/5] Preprocess (preprocess.preprocess_gallery) │ -│ Detección de texto (CTD para JP, dbconvnext para EN) │ -│ OCR (48px) → LLM translate → máscaras de inpaint │ -│ Dynamic detection con 4 fallback detectors │ -│ Pages en chunks de 50 con gc.collect() entre chunks │ -│ → data/work_{gid}/ regions/ y masks/ │ -└───────────────────────┬───────────────────────────────┘ - │ - ▼ -┌───────────────────────────────────────────────────────┐ -│ [3/5] Quality + Refine (refine.refine_gallery) │ -│ LLM polish con contexto completo de la galería │ -│ 2 pasadas de refinamiento por página │ -│ Chunking: si >50 págs, chunks de 50 │ -│ Timeout: n_chunks * 120s (mín 300s) │ -│ max_tokens=14000 (límite 16K del modelo) │ -└───────────────────────┬───────────────────────────────┘ - │ - ▼ -┌───────────────────────────────────────────────────────┐ -│ [4/5] Inpaint (inpaint_local.inpaint_gallery_local) │ -│ GPU auto-detection (CUDA > MPS > CPU) │ -│ Modelo global único: AOT (JP) o lama_large (EN/KR/CN) │ -│ Semaphore: min(max(cpu//2,3),8) páginas concurrentes │ -│ Missing/empty masks → copia original │ -│ → data/work_{gid}/ inpainted/ │ -└───────────────────────┬───────────────────────────────┘ - │ - ▼ -┌───────────────────────────────────────────────────────┐ -│ [5/5] Complete + MOBI (complete.complete_gallery_cbz) │ -│ V14 PIL renderer con textbbox real │ -│ Binary-search font size (24-64px, fallback 12-23px) │ -│ Flood-fill bubble detection + overlap resolution │ -│ → CBZ → KCC (--format MOBI --profile KPW34) → .mobi │ -│ → output/artifacts/{gid}/ + upload a Nextcloud │ -└───────────────────────────────────────────────────────┘ -``` - -### Enriched download metadata (`download_meta.json`) - -A partir del commit de /summary + /cover, `_download()` en `artifacts.py` escribe `download_meta.json` enriquecido con: - -| Campo | Tipo | Descripción | -|-------|------|-------------| -| `gid` | str | Gallery ID | -| `title` | str | Título en inglés | -| `title_jpn` | str | Título en japonés (si existe) | -| `config` | str | Ruta del config de traducción | -| `num_pages` | int | Número de páginas | -| `pages` | int | Alias backward-compat de `num_pages` | -| `is_spanish` | bool | Si ya está en español (skip translate) | -| `url` | str | URL original | -| `source` | str | `"nhentai"` o `"ehentai"` | -| `media_id` | str | media_id de nhentai (para cover URL) | -| `tags` | list[str] | Tags completos (`artist:xxx`, `parody:yyy`, etc.) | -| `artist` | str | Artista extraído de tags | -| `parody` | str | Parodia/franquicia extraída de tags | -| `cover_url` | str | URL externa de portada (nhentai CDN o e-hentai thumb) | - -Las galleries **viejas** (descargadas antes del cambio) no tendrán estos campos. El endpoint `/summary` hace fallback a `QueueItem` de `UrlQueue` para cubrir `media_id`/`title_jpn`/`artist`/`parody`. - ---- - -## bot_artifacts.py — Bot Telegram + API Server - -Archivo de **1586 líneas**. Se ejecuta como servicio systemd. Es el entry point más importante. - -### Arquitectura de Procesamiento (Two-Phase) - -El procesamiento de galleries se divide en dos fases con **semáforos separados**: - -``` - asyncio.Queue (_processing_queue) - ┌─────────────────────┐ - │ 6 Workers (_queue_worker) │ - │ Consume (url, bot, chat_id)│ - └──────────┬──────────┘ - │ - ┌────────────────────┼────────────────────┐ - │ Phase 1 (I/O) │ Phase 2 (CPU) │ - ▼ ▼ ▼ -┌──────────────────┐ ┌────────────────────┐ -│ _download_sem │ │ _translate_sem │ -│ MAX_CONCURRENT=2 │ │ MAX_CONCURRENT=2 │ -│ artifacts. │ │ _process_one() → │ -│ download_only() │ │ artifacts.process()│ -│ (descarga CBZ) │ │ (translate+inpaint │ -│ │ │ +render+MOBI+up) │ -└──────────────────┘ └────────────────────┘ -``` - -### Inicialización (`main()` + `_post_init`) - -1. `Application.builder().token(settings.telegram_bot_token).build()` -2. Registra CommandHandlers + MessageHandler -3. `_post_init` corre AL ARRANCAR el bot: - 1. Notifica "Bot encendido" a `ALLOWED_CHAT_ID` - 2. Lanza 6 workers (`_queue_worker`) - 3. `_drain_pending_on_startup(app)` — recupera galleries pendientes/fallidas - 4. Si `settings.api_enabled` → lanza `_start_api_server()` - 5. `_periodic_cleanup_loop()` — cleanup cada 24h - 6. `_daily_nhentai_feed_loop(app)` — feed diario a las 6AM ART (UTC-3) - -### Comandos de Telegram - -| Comando | Handler | Descripción | -|---------|---------|-------------| -| `/start` | `start_command` | Texto de ayuda listando todos los comandos | -| `/status` | `status_command` | Galleries activas, slots, UrlQueue stats, CPU/RAM, fallidas | -| `/retry [gid]` | `retry_command` | Reintentar todas o una gallery fallida | -| `/clean` | `clean_command` | Limpiar archivos >72h (vía `src.cleaner.clean_old_files`) | -| `/b ` | `bypass_command` | Procesar gallery saltando filtros de tags | -| `/update` | `nhfeed_command` | Activar manualmente el feed de portada de nhentai.net | -| `/nhfeed` | `nhfeed_command` | Alias de /update | -| `/wipe` | `wipe_command` | Limpiar cola + work dirs (NO borra output/artifacts ni Nextcloud) | - -### Message Handler: `handle_message` - -Detecta URLs en el mensaje usando `MessageEntity` (offset+length exacto) con fallback a regex. - -**Tipos de URL** y sus rutas: - -| Tipo URL | Regex | Routing | -|----------|-------|---------| -| `gallery` | `e-hentai.org/g/(\d+)/([a-f0-9]+)` o `nhentai.net/g/(\d+)` | `_processing_queue.put()` → `_queue_worker` → `_process_gallery_url()` | -| `search` | `e-hentai.org/?` | `asyncio.create_task(_process_search_url())` | -| `nh_search` | `nhentai.net/search?` | `asyncio.create_task(_process_nhentai_search_url())` | -| `nh_artist` | `nhentai.net/artist/([a-z0-9_-]+)` | `asyncio.create_task(_process_nhentai_artist_url())` | - -**Dedup en handle_message** para URLs de tipo `gallery`: -1. Si ya está `processing` o `pending` → skip -2. Si ya completada (artifacts dir o status completed) → skip -3. Si fallida antes → limpia work_dir, resetea a pending, reintenta -4. Si nueva → `UrlQueue.add()` + encola - -### Funciones Internas Clave - -| Función | Firma | Rol | -|---------|-------|-----| -| `_queue_worker` | `(worker_id: int) -> None` | Loop infinito: consume `_processing_queue`, llama `_process_gallery_url` | -| `_process_gallery_url` | `(url, bot, chat_id, bypass_filter=False)` | **Two-phase**: download_sem → `artifacts.download_only()`; translate_sem → `_process_one()` | -| `_process_one` | `(url, bot, chat_id) -> bool` | Fase 2: `artifacts.process()` → CBZ → `send_to_kindle()` → actualiza stats | -| `_process_search_url` | `(url, bot, chat_id)` | Búsqueda e-hentai: scrape → batch metadata → dedup 5 niveles → batch processing | -| `_process_nhentai_search_url` | `(url, bot, chat_id)` | Búsqueda nhentai: content filter → FC→B&W redirect → dedup → batch processing | -| `_process_nhentai_artist_url` | `(url, bot, chat_id)` | Artista nhentai: mismo flujo que search | -| `_find_bw_alternative` | `(detail) -> NhentariGallery \| None` | Busca versión B&W alternativa cuando `should_download` bloquea por full color | -| `_check_duplicate` | `(gid, url, metadata) -> str \| None` | 5 niveles: artifacts dir → work dir → work_key → parent key → SQLite fingerprint | -| `_gdata_metadata` | `(gid, token) -> dict \| None` | Fetch metadata de e-hentai gdata API | -| `_drain_pending_on_startup` | `(application)` | Recupera URLs pendientes + fallidas post-reinicio | -| `_periodic_cleanup_loop` | `()` | Cleanup cada 24h via `src.cleaner.clean_old_files()` | -| `_daily_nhentai_feed_loop` | `(app)` | Feed diario nhentai a las 6AM ART | -| `_start_api_server` | `()` | Inicia FastAPI/uvicorn en el mismo event loop | -| `_wipe_all` | `()` | Wipe standalone (sin bot) — llamado por API DELETE /queue | -| `_clean_idle_memory` | `()` | Limpia cachés globales de manga-translator + `gc.collect()` cuando idle | -| `_load_failed` / `_save_failed` | `() -> list` / `(list)` | Persistencia de fallidas en `data/failed_galleries.json` | -| `_add_failed` / `_remove_failed` | `(url, gid, title, error)` / `(gid)` | Manager de lista de fallidas | -| `_extract_urls` | `(text, entities) -> list[(url, type)]` | Extrae URLs + tipo del texto del mensaje | -| `_extract_nh_query` | `(url) -> str` | Extrae `?q=` de URL de búsqueda nhentai | -| `_extract_nh_artist_sort` | `(url) -> str` | Extrae `?sort=` de URL de artista nhentai | -| `_build_parent_work_key` | `(title, title_jpn, tags) -> str` | Work key que ignora chapter/volume markers | -| `_detect_batch_lang` | `(tags) -> str` | Detecta idioma de tags para dedup batch | -| `_send_batch_summary` | `(batch) -> None` | Envía resumen de batch processing | -| `_notify_batch_done` | `(batch_id) -> None` | Tracking interno de progreso de batch | - -### Variables Globales - -```python -MAX_CONCURRENT = 2 # Slots de download y translate -_NUM_WORKERS = 6 # Workers de la cola -_translate_sem = Semaphore(2) -_download_sem = Semaphore(2) -_stats = {"completed": 0, "failed": 0, "active": 0} -_wiping = False -_processing_queue: asyncio.Queue = asyncio.Queue() -_batch_tracker: dict[str, dict] = {} -_LANG_PRIORITY = {"english": 0, "spanish": 1, "japanese": 2, ...} -FAILED_FILE = Path("data/failed_galleries.json") -ARTIFACTS_DIR = Path("output/artifacts") -``` - -### Restart Recovery - -- `_drain_pending_on_startup()` corre en `post_init` -- UrlQueue se persiste en `data/bot_queue.json` (JSON) -- Items en estado "processing" al reiniciar se resetean a "pending" (crash recovery) -- `failed_galleries.json` también se drena al arranque -- `reload_from_disk()` es función **module-level** en `url_queue.py` - ---- - -## REST API (FastAPI) — `src/api/` - -Servidor FastAPI en el mismo event loop que el bot de Telegram. Corre en `0.0.0.0:8080` por defecto. - -### Inicio - -```python -# bot_artifacts.py:1497-1498 y 1550-1582 -if settings.api_enabled: - asyncio.create_task(_start_api_server()) - -async def _start_api_server(): - api_app = create_app( - download_sem=_download_sem, - translate_sem=_translate_sem, - process_gallery_url=_process_gallery_url, - wipe_fn=_wipe_all, - api_key=settings.api_key, - ... - ) - config = uvicorn.Config(api_app, host=..., port=..., log_level="warning") - server = uvicorn.Server(config) - await server.serve() -``` - -### Estructura de Archivos - -| Archivo | Rol | -|---------|-----| -| `src/api/__init__.py` | Factory `create_app()` — recibe semáforos y callbacks | -| `src/api/main.py` | `create_fastapi_app()` — monta middleware CORS, auth, routers | -| `src/api/auth.py` | `ApiKeyMiddleware` — auth via header `X-API-Key` | -| `src/api/deps.py` | Inyección de dependencias (semáforos, funciones) | -| `src/api/models.py` | Schemas Pydantic + helpers `ok()`, `err()`, `slot_info()` | -| `src/api/errors.py` | Rate limiter token-bucket + exception handlers | -| `src/api/routes/__init__.py` | Agregación de routers | -| `src/api/routes/system.py` | `GET /health`, `GET /status` | -| `src/api/routes/galleries.py` | CRUD de galleries | -| `src/api/routes/queue.py` | Queue management | -| `src/api/routes/search.py` | Search endpoints | - -### Endpoints - -| Método | Ruta | Descripción | Auth | Rate Limit | -|--------|------|-------------|------|------------| -| `GET` | `/api/v1/health` | Health check simple (uptime, version) | ❌ Público | — | -| `GET` | `/api/v1/status` | Estado completo: cola, slots, activas, RAM, CPU, modelo actual | ✅ | — | -| `POST` | `/api/v1/galleries` | Enviar URL para procesamiento completo | ✅ | Submit | -| `POST` | `/api/v1/galleries/download` | Solo descarga (fase 1) | ✅ | Submit | -| `GET` | `/api/v1/galleries` | Lista galleries (filtro por status + paginación) | ✅ | — | -| `GET` | `/api/v1/galleries/{gid}` | Detalle de una gallery | ✅ | — | -| `GET` | `/api/v1/galleries/{gid}/summary` | Resumen completo: title, title_jpn, tags, artist, parody, cover URL, pipeline status | ✅ | — | -| `GET` | `/api/v1/galleries/{gid}/cover` | Foto de portada (página 1) como FileResponse | ✅ | — | -| `GET` | `/api/v1/galleries/{gid}/artifacts` | Archivos generados | ✅ | — | -| `DELETE` | `/api/v1/galleries/{gid}` | Cancelar y eliminar gallery | ✅ | — | -| `POST` | `/api/v1/search` | Buscar en nhentai/e-hentai (sin procesar) | ✅ | General | -| `POST` | `/api/v1/search/process` | Buscar + auto-encolar resultados no bloqueados | ✅ | Submit | -| `GET` | `/api/v1/queue` | Lista cola (filtro + paginación) | ✅ | — | -| `GET` | `/api/v1/queue/stats` | Estadísticas de cola + slots | ✅ | — | -| `POST` | `/api/v1/queue/{gid}/retry` | Reintentar gallery fallida | ✅ | — | -| `DELETE` | `/api/v1/queue` | Wipe completo (equivalente a `/wipe`) | ✅ | — | - -Swagger UI en `GET /docs`, ReDoc en `GET /redoc`, schema en `GET /openapi.json`. - -### Autenticación - -- **Middleware**: `ApiKeyMiddleware` (Starlette `BaseHTTPMiddleware`) -- **Header**: `X-API-Key` -- **Bypass paths**: `/api/v1/health` siempre público -- **Bypass IPs**: `127.0.0.1`, `::1`, `::ffff:127.0.0.1` -- **Dev mode**: si `api_key` está vacío en `.env`, no requiere auth -- **401 response**: `{"error": {"code": "unauthorized", "message": "Missing or invalid API key"}}` - -### Rate Limiting - -Token bucket in-memory por IP: -- **General**: `api_rate_limit_per_min` (default 60/min) — aplica a GETs y search -- **Submit**: `api_submit_limit_per_min` (default 10/min) — aplica a POST gallerias + search/process -- **429 response**: `{"error": {"code": "rate_limited", "message": "..."}}` - -### NullBot - -La API usa `_NullBot` como reemplazo de `telegram.Bot` para los mensajes de progreso. Los absorbe y los envía a logger en vez de a Telegram. - -### CORS - -Permite todos los orígenes (`*`) para scripts locales. Lockear en producción vía nginx. - ---- - -## Módulos del Pipeline (`src/translator/`) - -### `preprocess.py` — Detección, OCR, Traducción, Máscaras - -- `preprocess_gallery(gallery_dir, device)`: Procesa página por página - - Detecta texto (dynamic detection con 4 fallbacks) - - OCR con tesseract/manga-ocr - - LLM translate (proxy OpenAI-compatible) - - Genera máscaras de inpaint (por región, con `_rebuild_mask_per_region`) -- Chunks de 50 páginas con `gc.collect()` entre chunks -- RSS threshold: `min(max(total_mem * 0.7, 6.0), 24.0)` GB -- Timeout por página: 600s (env `PAGE_TIMEOUT`) -- Concurrencia dinámica: `set_preprocess_concurrent(n)` - -### `refine.py` — LLM Polish - -- `refine_gallery(gallery_dir, chunk_size=0)`: - - 2 pasadas de refinamiento LLM por página - - Chunking automático (>50 pages → chunks de 50) - - Timeout dinámico: `n_chunks * 120s` (mín 300s) - - `_call_llm()` con `max_tokens=14000` -- El modelo real es el mismo que usa traducción (seteado vía `os.environ["OPENAI_MODEL"]`) -- El default `mimo-v2.5-free` en refine.py es un **fallback muerto** — nunca se usa - -### `inpaint_local.py` — Inpainting - -- `inpaint_gallery_local(gallery_dir, device)`: - - Modelo global único (AOT para JP, lama_large para EN/KR/CN) - - Semaphore: `min(max(cpu_count // 2, 3), 8)` páginas concurrentes - - Missing/empty masks → copia original - - Failed → fallback a original - -### `complete.py` — Render V14 + CBZ Packaging - -**V14 Renderer** (PIL-based, producción): -- `render_page_v14(inpainted_img, regions_data, mask_gray) -> np.ndarray`: - - Flood-fill bounded bubble detection sobre imagen inpainted - - Binary-search font size con PIL `textbbox` (24-64px, fallback 12-23px) - - White stroke para legibilidad - - Iterative overlap resolution (3 pasadas, shift 25-35px) - - Character-by-character font fallback para símbolos no soportados - - CJK stripping para targets no-CJK - - Normaliza puntuación CJK (… → ..., ‼ → !!) - - Spanish word hyphenation (`_split_word_spanish`) - - `_draw_text_with_fallback`: si un carácter no está en el font principal, usa Arial Unicode - -- `complete_gallery_cbz(gallery_dir, inpainted_dir, output_cbz) -> Path`: - - Procesa cada página: render V14 si tiene texto, inpainted direct si no - - Empaqueta en CBZ (JPEG quality 92) - - Pipeline state tracking por página - -### `dynamic_detection.py` — Adaptive Detection - -- `compute_adaptive_config(config_dict, image) -> dict`: Ajusta `detection_size`, `text_threshold`, `box_threshold`, `unclip_ratio` según: - - Tamaño de imagen: <1MP→1024, 1-3MP→1536, >3MP→2048 - - Contraste: <30→th 0.1, 30-50→0.15, >50→0.2 - - Text scale (Laplacian edge density) - -- Fallback detector chain (4 intentos): - 1. Config adaptativa - 2. Detector `default` con thresholds bajos (0.15/0.2), 1536px - 3. `ctd` con thresholds mínimos (0.1/0.15), 1024px - 4. `default` con thresholds mínimos, 2048px - -### `language.py` — Detección de Idioma + Config Routing - -- `detect_language(tags) -> str`: Detecta idioma de tags (e-hentai `language:japanese` o nhentai `japanese`) -- `select_config_path(language) -> str`: Mapea idioma a archivo de configuración -- `write_metadata(gallery_dir, gid, title, tags, source, language)`: Escribe `source_metadata.json` -- Priority: Spanish > English > Korean > Chinese > Japanese -- Config files: japonés→`translate_config.json`, otros→ `translate_config_english/korean/chinese.json` - -### `pipeline_state.py` — Tracking de Estado - -```python -_active_galleries: dict[str, PipelineState] = {} -``` - -- `set_pipeline_state(gallery_id, phase, done, total)`: Actualiza estado (llamado desde preprocess, inpaint, render, mobi, upload) -- `get_active_galleries() -> dict`: Usado por `/status` y API `GET /status` -- `clear_pipeline_state(gallery_id)`: Limpia al completar/fallar - -### `queue.py` — GalleryQueue - -- JSON-backed singleton en `data/modal_queue/queue.json` -- Thread-safe via `threading.Lock` — múltiples instancias comparten estado -- Gallery dirs en `data/modal_queue/gallery_{gid}/` con `images/`, `masks/`, `regions/` -- Status: `pending → processing → completed/failed` -- Métodos: `add()`, `get()`, `mark_processing()`, `mark_completed()`, `mark_failed()`, `set_cbz_path()`, `set_priority()`, `reset_to_pending()`, `remove()`, `cleanup_completed()`, `get_pending()`, `get_incomplete()`, `get_failed()` - -### `config.py` — TranslationConfig - -```python -@dataclass -class TranslationConfig: - api_key: str = "" - api_base: str = "https://api.z.ai/api/coding/paas/v4" - model: str = "GLM-5.1" - target_lang: str = "ESP" - config_path: str = "translate_config.json" -``` - -- `from_settings(settings)`: build desde `Settings` (usa `minimax_api_key` como fallback) - -### `runner.py` — CLI Wrapper para manga-image-translator - -- `run_translator(input_dir, output_dir, config)`: Corre `python -m manga_translator local` -- Parallel batch processing: divide imágenes en batches, corre concurrentemente -- CPU optimization flags: `OMP_NUM_THREADS`, `MKL_NUM_THREADS`, etc. -- Fallback: copia originales para páginas sin texto detectado -- Workers: `min(max(cpu_count // 2, 3), 8)` - -### `__init__.py` — translate_manga() - -- `translate_manga(cbz_path, config, output_dir=None) -> Path`: Alto nivel para CBZ - - Extrae CBZ → corre translator → repack → devuelve path del CBZ traducido - - On failure **RAISES** (no fallback al original) - -### `_threadloop.py` — Thread-Local Event Loops - -- Cada thread worker tiene su propio event loop persistente (evita `asyncio.run()` y memory leaks) -- `run_coro(coro)`: corre coroutine en el loop del thread actual -- `get_translator()`: crea/obtiene MangaTranslator thread-local con HTTP client propio -- Dummy 64x64 al init fuerza carga de modelos en el thread correcto -- PyTorch model weights SHARED globalmente (solo HTTP client es per-instance) - -### `api_fallback.py` — API Fallback y Model Scoring - -- Primary proxy: `http://127.0.0.1:6446/v1` (OpenAI-compatible local) -- NSFW fallback: NVIDIA API con `minimaxai/minimax-m3` -- Proxy health-check via `GET /v1/models` cada 30 segundos -- **Model scoring**: success +0.1 (max 2.0); retryable errors -0.2 (min 0.1); hard errors -0.5 (min 0.1) con streak penalty -- **Circuit breaker**: - - `503` / service unavailable → banned 120s - - `401` / unauthorized → banned 1800s (30 min — dead model) - - `pick_model()` filtra modelos baneados -- Default target: `nvidia/nemotron-3-ultra-550b-a55b:free` -- Blocklist: `stepfun/step-3.7-flash:free` (returns empty/None) -- Scores persisten en `data/model_scores.json` -- `setup_api(pick_model())` setea `os.environ["OPENAI_MODEL"]` **antes** de cualquier refine call - ---- - -## Configs de Traducción por Idioma - -| Config | Detector | Detection Size | Inpainter | Inpaint Size | Font Offset | Mask Dilation/Kernel | -|--------|----------|---------------|-----------|-------------|-------------|---------------------| -| `translate_config.json` (JP) | ctd | 2048 | default (AOT) | 1024 | +10 | 35/5 | -| `translate_config_english.json` (EN) | default | 2048 | **lama_large** | 2048 | +10 | **55/8** | -| `translate_config_korean.json` (KR) | default | 2048 | **lama_large** | 2048 | +10 | **55/8** | -| `translate_config_chinese.json` (CN) | default | 2048 | **lama_large** | 2048 | +10 | **55/8** | - -- Todos usan renderer `manga2eng`, OCR `48px`, target `ESP`, translator `chatgpt` -- EN/KR/CN requieren `lama_large` con dilation/kernel más alto (texto no-CJK ocupa más espacio) -- JP usa detector CTD (entrenado en kanji); otros usan default (dbconvnext) - ---- - -## Sistema de Colas - -### UrlQueue (`src/url_queue.py`) — Cola del Bot - -- JSON-backed (thread-safe) -- Persiste en `data/bot_queue.json` -- Items clave: `gid`, `url`, `token`, `title`, `work_key`, `is_spanish`, `filecount`, `status` -- Status: `pending → processing → completed/failed` -- Crash recovery: items en "processing" al cargar disco se resetean a "pending" -- Dedup via `work_key`: fingerprint canónico de `title_jpn` (tier 1) o `artist + clean_title` (tier 2) -- `media_id_cache` separada (`data/media_id_cache.json`) mapea e-hentai gid → nhentai media_id -- Funciones clave: `add()`, `get()`, `find_duplicate()`, `claim_next_pending()`, `mark_completed()`, `mark_failed()`, `reset_to_pending()`, `get_pending()`, `get_all()` -- `build_work_key(tags, title, title_jpn) -> str`: Construye key canónico -- `reload_from_disk()`: **module-level function** para recargar desde disco -- `reload_from_disk()` en `queue.py`: Force-reload para scripts externos - -### GalleryQueue (`src/translator/queue.py`) — Cola del Pipeline - -- JSON-backed singleton en `data/modal_queue/queue.json` con estado compartido module-level -- Thread-safe via `threading.Lock` -- Gallery dirs en `data/modal_queue/gallery_{gid}/` con `images/`, `masks/`, `regions/` -- Diferencia clave con UrlQueue: esta es para el **pipeline de traducción local**, no para el bot -- `QueuedGallery`: `gallery_id`, `title`, `source`, `num_pages`, `status`, `cbz_path`, `priority`, `language`, `token` - ---- - -## Pipeline Orchestration (`src/pipeline.py`) - -- `run_pipeline_once(settings)`: Entry point único - - Retry failures → preprocess → inpaint → render → MOBI → deliver -- Llamado desde `cli.py worker` (loop) y `cli.py prioridad`/`manga` (inline) -- Paralelismo: `min(max(cpu_count // 4, 3), 6)` galleries en paralelo -- Timeout por gallery: `max(num_pages * 120, 1800)` segundos -- Permanent error patterns (no retry): "opencv", "content filtered", "no images found" - ---- - -## Scrapers - -### nhentai (`src/nhentai_scraper/`) - -- Scrapling `StealthySession` para Cloudflare bypass -- CDN download preferido: `i.nhentai.net/galleries/{media_id}/{page}.{ext}` (~0.1s/page) -- e-hentai fallback para no-Spanish -- Content filter: skip non-manga, non-JP source, <10 pages -- Rate limiting + retry con exponential backoff -- Funciones: `scrape_search`, `search_nhentai`, `_fetch_gallery_v2`, `should_download`, `fetch_gallery_links_httpx_async`, `safe_nh_api_get` - -**Dedup** (`src/nhentai_scraper/dedup.py`): -- `select_winners(raw_galleries, state_db, notify_fn) -> DedupReport`: - - Phase 1: Enrich each raw gallery with v2 metadata - - Phase 1b: Bridge galleries without title_jpn via EN-title matching - - Phase 2: Group by fingerprint - - Phase 3: DB cross-reference + select winner per group - - Winner selection: Spanish > English > Japanese > Chinese, then page count, then higher GID -- `DedupReport`: `groups`, `no_fingerprint`, `winners`, `stats` -- `format_dry_run_table(report)`: Render tabla legible para dry-run -- `detect_language(tags, title_jpn)`: Detecta idioma via title markers (más confiable que tags) - - Priority 1: title_jpn markers (`[スペイン翻訳]`, `[英訳]`, etc.) - - Priority 2: tags (bare or prefixed) - -### e-hentai (`src/hentai_scraper/` + `src/ehentai_search.py`) - -- API method: `gdata` (no legacy `gmetadata`) -- Front page scrape para scheduler diario; metadata fetch via API -- `search_spanish_version(title_jpn, artist_tag, title)`: Busca versión española en e-hentai - - Step 1: Búsqueda específica (artist + title keywords + language:spanish) - - Step 2: Fallback a búsqueda amplia (artist + language:spanish) + CJK title matching - - Match por fingerprint de título (CJK o EN) -- `scrape_front_page(category_mask, max_pages)`: Scrapea front page de e-hentai -- `fetch_metadata_batch(galleries)`: Fetch metadata batch via API (25 por lote) - -### EhentaiMangaScheduler (`src/ehentai_manga_scheduler.py`) - -Scheduler diario que reemplaza a `HentaiScheduler` + `NhentaiScheduler` legacy. - -``` -Front page e-hentai (Manga + Doujinshi) - → Metadata batch API - → Filter: only Manga/Doujinshi, not processed, content-safe - → Dedup + fingerprint grouping (ESP > EN > JP > CN) - → Por cada gallery: - ├─ Spanish tag → download (nhentai CDN preferido, fallback e-hentai) → MOBI → upload - └─ Not Spanish → search_spanish_version() - ├─ Found Spanish → download esa versión → MOBI → upload - └─ Not found → download de nhentai (o e-hentai) + GalleryQueue para traducción -``` - -- `_probe_nhentai_cdn(gid)`: Verifica si gallery existe en nhentai CDN -- `_resolve_nhentai_media_id(gid, title)`: Resuelve media_id correcto (fast: gid, slow: v2 API search) -- `_search_nhentai_v2(title)`: Busca en nhentai v2 API por título -- `_download_and_upload(meta)`: Download + KCC + Nextcloud upload -- `_download_and_translate(meta)`: Download + GalleryQueue encolado para traducción - ---- - -## Fansub Pipeline (`src/fansub/`) - -Pipeline alternativo para nhentai galleries (independiente de `src/translator/`). - -``` -nhentai gallery - → RT-DETR ONNX detection (class 0=bodies, 1=text, 2=frames) - → MiniMax VLM OCR/translation - → TELEA inpainting - → Pillow typesetting - → CBZ -``` - -- ONNX model en `/tmp/rtdetr/model.onnx` (descargado en Docker build) -- Activado via `fansub_enabled=True` en `nhentai_scraper/scheduler.py` -- Text regions grouped via Union-Find clustering (25px proximity) -- Fallback font: `fonts/Anime_Ace_3.ttf` -- `FansubPipeline` class en `__init__.py`, `run_fansub_pipeline()` en `pipeline.py` -- Módulos: `detector.py`, `text_detector.py`, `translator.py`, `redrawer.py`, `typesetter.py`, `cleaner.py`, `packager.py`, `config.py` -- Requirements propios: `src/fansub/requirements_fansub.txt` - ---- - -## State Database (`src/base_state.py`) - -- SQLite via `BaseStateDB` — nunca escribir raw SQL fuera de este módulo -- Subclasses: - - `HentaiStateDB` — e-hentai gallery state - - `NhentaiStateDB` — nhentai gallery state - - `MangaStateDB` — manga (MangaWatcher) state -- Status: `pending → downloading → downloaded → processing → completed → failed` -- `find_by_fingerprint(fp)`: Cross-db dedup por fingerprint de title_jpn - -**Dual State Tracking (KNOWN ISSUE)**: Tanto SQLite (`NhentaiStateDB`/`HentaiStateDB`) como GalleryQueue/UrlQueue trackean estado de galleries SIN reconciliación cruzada. - ---- - -## Delivery - -### Nextcloud (`src/nextcloud_uploader.py`) - -```python -send_to_kindle(path, settings, subfolder) -``` - -- `sudo cp + chown www-data:www-data` al data dir de Nextcloud -- `docker exec -u 33 nextcloud occ files:scan --path=...` para indexar -- No WebDAV — copia directa al filesystem - -### Local (`src/local_uploader.py`) - -```python -send_to_kindle(path, settings, subfolder) -``` - -- `shutil.copy2` a `output_dir/{subfolder}/` -- Backend default cuando `delivery_backend=local` - ---- - -## Notifications (`src/notifier.py`) - -- Protocol-based: `Notifier` Protocol class -- Implementaciones: - - `ConsoleNotifier` — log a stdout - - `TelegramNotifier` — envía mensajes a Telegram - - `NullNotifier` — no-op (default) -- Registro global via `utils.set_notifier(notifier)` al startup -- Uso: `utils.notify(message=...)` desde cualquier módulo - ---- - -## GPU/Device Auto-Detection (`src/device.py`) - -```python -def get_device() -> str: # "cuda" | "mps" | "cpu" -def prefer_gpu() -> bool: # get_device() != "cpu" -``` - -- No config, no env vars — detecta en runtime via `torch.cuda.is_available()` / `torch.backends.mps.is_available()` -- Usado por: `_threadloop.py` (MangaTranslator `use_gpu`), `inpaint_local.py` (inpainting device), `artifacts.py`/`process.py` - ---- - -## Utilidades Compartidas (`src/utils.py`) - -| Función | Descripción | -|---------|-------------| -| `set_notifier(notifier)` | Registra notificador activo | -| `notify(message)` | Despacha mensaje via notificador activo | -| `gallery_date_folder()` | Fecha UTC como `M_D_YYYY` (portable, sin zero-padding) | -| `sanitize_filename(name, max_len)` | Sanitiza nombre de archivo (elimina chars inseguros) | -| `format_gallery_name(lang, title, gid)` | Formato `[LANG] (title) (gallery_id)` | -| `normalize_title_for_dedup(title)` | Normaliza título EN para dedup cross-source | -| `normalize_jp_title_for_dedup(title_jpn)` | Normaliza título JP para dedup (strip brackets, chapter markers, etc.) | -| `title_has_english(title)` | Check si título tiene `[English]` | -| `title_has_spanish(title)` | Check si título tiene `[Spanish]` | -| `format_summary(downloaded, failed)` | Formatea resumen de descargas | - ---- - -## Otros Módulos - -### `src/converter.py` - -Conversión de formatos: EPUB, PDF, CBR, CBZ, ZIP → Kindle format. -- `is_image_pdf(src)`: Detecta si PDF es imagen vs texto -- `convert(src, fmt)`: Detecta formato y convierte -- `_fetch_metadata(stem)`: Fetch metadata via `fetch-ebook-metadata` -- `_safe_extract(zf, dest)`: ZIP extraction segura (previene path traversal) - -### `src/file_handler.py` - -- `download_file(update, tmp_dir)`: Descarga archivo de Telegram -- `download_from_url(url, tmp_dir, timeout)`: Stream-download desde URL -- `cleanup_tmp_dir(tmp_dir)`: Elimina directorio temporal - -### `src/cleaner.py` - -- `clean_old_files(artifacts_dir, data_dir, max_age_hours, dry_run) -> CleanupStats`: - - Elimina archivos >72h de `output/artifacts/` y `data/work_*` - - Protege `bot_queue.json`, `manga_state.db`, `failed_galleries.json`, `model_scores.json`, `media_id_cache.json` - - Soportado como script standalone: `python -m src.cleaner --hours 72 --dry-run` - -### `src/libgen.py` - -Búsqueda en Library Genesis: -- `search_books(query, by_author=False) -> list[Book]`: Busca y devuelve top 5 (EPUB first) -- `resolve_download_url(book) -> str | None`: Resuelve URL de descarga directa -- `Book`: id, md5, title, author, year, pages, language, extension, size, mirrors -- Usa BeautifulSoup para parsear HTML de resultados - -### `src/manga_watcher.py` - -- `MangaWatcher(config, bot_app)`: Vigila carpeta de Nextcloud para nuevos mangas - - Polling loop cada `watch_interval` segundos - - `_validate(filepath)`: Verifica extensión y contenido (imágenes dentro de ZIP/CBR) - - `_translate_if_enabled(filepath)`: Traduce si `translation_enabled=True` - - `_convert(input_path)`: KCC → MOBI - - `_upload_to_nextcloud(output_dir, title)`: Upload a Nextcloud - - Trackea estado via `MangaStateDB` - -### `src/exceptions.py` - -```python -KindleError(Exception) # Base - ├─ ConversionError # Conversión fallida - ├─ UploadError # Upload fallido - ├─ ScrapingError # Scraping/download fallido - └─ TranslationError # Traducción fallida +src/ +├── lib/ +│ ├── db.ts # SQLite: tabla posts + capa de datos completa +│ ├── auth.ts # JWT HS256: createSession / verifySession +│ ├── api.ts # Cliente del pipeline REST (API_BASE_URL) +│ ├── types.ts # Interfaces (GallerySummary, etc.) +│ ├── cbz.ts # loadPagesFromCbz (JSZip) — cliente +│ ├── cover-cache.ts # cacheCover / getCoverPath (disco, por gid) +│ ├── poller.ts # startPoller / pollOnce — sync pipeline→posts +│ ├── settings.ts # initSettings / get / saveSettings (JSON) +│ ├── slug.ts # slugify / cleanTitle +│ └── utils.ts # formatSize, extractTag, cn, etc. +├── middleware.ts # Auth por prefijo de path (ver §Auth) +├── instrumentation.ts # register() → initSettings + startPoller (1 vez) +├── app/ +│ ├── layout.tsx # Root: , metadata, Inter +│ ├── globals.css # Tema oscuro + @utility Tailwind v4 +│ ├── (public)/ # GRUPO PÚBLICO — server-side, sin login +│ │ ├── layout.tsx # Header sticky + nav "Buscar" +│ │ ├── page.tsx # / grid de posts publicados +│ │ ├── p/[slug]/page.tsx # detalle de post (fansub) +│ │ └── tag/[tag]/page.tsx # posts por tag +│ ├── (app)/ # GRUPO PRIVADO — client-side, login requerido +│ │ ├── layout.tsx # Sidebar desktop + bottom-nav móvil +│ │ ├── feed/page.tsx # /feed galleries del pipeline +│ │ ├── gallery/[gid]/page.tsx # detalle gallery +│ │ ├── gallery/[gid]/read/page.tsx # lector (paged/webtoon) +│ │ ├── queue/page.tsx # /queue estado pipeline +│ │ ├── search/page.tsx # /search nhentai/e-hentai +│ │ ├── submit/page.tsx # /submit encolar URLs +│ │ └── admin/posts/page.tsx + [id]/page.tsx # CRUD posts +│ ├── login/page.tsx # /login (password) +│ ├── setup/page.tsx # /setup primer arranque (4 campos) +│ └── api/ # Route handlers (ver §API) +├── components/ # cover-image, gallery-card, gallery-grid, +│ │ # post-card, tag-badge, filter-bar, +│ │ # search-form, submit-form, empty-state, +│ │ # layout/sidebar +├── hooks/ # use-galleries, use-queue, use-status +data/ # worst-scan.db (SQLite), covers/, (runtime) +deploy/ # Caddyfile, setup.sh, worst-scan-web.service +scripts/ # start.js, setup.sh ``` --- -## Scripts (`scripts/`) +## Base de Datos (`src/lib/db.ts`) -| Script | Descripción | -|--------|-------------| -| `batch_convert_cbz.py` | Batch KCC conversion de CBZs en Nextcloud | -| `download_type90.py` | Descargar TYPE.90 español, convertir, subir | -| `llama_server.ps1` | PowerShell management de llama.cpp server local | -| `test_pipeline_653011.py` | Test end-to-end nhentai pipeline | +SQLite vía `better-sqlite3`. Ruta: `process.env.DB_PATH` o `data/worst-scan.db`. WAL + `foreign_keys = ON`. + +**Tabla `posts`:** + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INTEGER | PK AUTOINCREMENT | +| `gid` | TEXT | NOT NULL, UNIQUE — id de la gallery del pipeline | +| `title` | TEXT | NOT NULL | +| `title_jpn` | TEXT | nullable | +| `artist` | TEXT | nullable | +| `parody` | TEXT | nullable | +| `tags` | TEXT | JSON serializado (`string[]`) | +| `num_pages` | INTEGER | DEFAULT 0 | +| `source` | TEXT | `nhentai` / `ehentai` | +| `cover_url` | TEXT | nullable | +| `url` | TEXT | nullable | +| `summary` | TEXT | nullable | +| `slug` | TEXT | UNIQUE NOT NULL — URL amigable `/p/{slug}` | +| `published` | INTEGER | DEFAULT 0 | +| `created_at` | TEXT | `datetime('now')` | +| `updated_at` | TEXT | `datetime('now')` | +| `published_at` | TEXT | nullable | + +**Índices:** `idx_posts_gid`, `idx_posts_slug` (redundantes con UNIQUE), `idx_posts_published`, `idx_posts_created(created_at DESC)`. + +**Funciones de la capa de datos:** `getDb()`, `migrate()`, `rowToPost()`, `getAllPosts(publishedOnly)`, `getPostById()`, `getPostByGid()`, `getPostBySlug()`, `createPost()`, `updatePost()`, `publishPost()`, `deletePost()`, `getPostsByTag()` (usa `json_each`), `getPostCount()`, `getGidsNotInPosts()` (**stub — devuelve `[]`**). + +> ⚠️ **`createPost` publica por defecto** (`published = input.published ?? 1`) — todo post nuevo sale publicado. +> ⚠️ **`updatePost` interpola los nombres de columna del body sin whitelist** → riesgo de SQL injection (ver §Bugs). --- -## KCC Wrapper (`src/kcc.py`) +## Autenticación (`src/lib/auth.ts` + `src/middleware.ts`) -- `kcc-c2e` en su propio grupo de procesos con timeout (**600s default**) -- Si el proceso cuelga: `kill -- -$PGID` (Linux) o `taskkill /T /F` (Windows) -- File-based completion detection (espera archivo `.mobi`, polling) -- Flags: `--format MOBI --profile KPW34 --manga-style --stretch --splitter 2` +- **Cookie:** `session` (httpOnly, `secure` en production, sameSite lax, 7d). +- **Token:** JWT HS256, payload `{ authenticated: true, timestamp }`, expiración `7d`. +- **Secret:** `process.env.JWT_SECRET || process.env.WEB_PASSWORD || "worst-scan-web-dev-secret"` ⚠️ fallback predecible. +- **Login:** `POST /api/auth/login` con `{ password }` → compara con `WEB_PASSWORD`. **Si no hay `WEB_PASSWORD` configurada, emite sesión sin verificar nada.** +- **Logout:** `POST /api/auth/logout` borra la cookie (el JWT no se revoca, solo expira). +- **Middleware** (`src/middleware.ts`): paths en `publicPaths` → allow; páginas `/`, `/p/`, `/tag/` → allow; **sin `WEB_PASSWORD` → TODO abierto**; si no, exige cookie válida → redirect `/login`. + +**`publicPaths`:** `/login`, `/api/auth/login`, `/api/auth/logout`, `/api/health`, `/api/proxy/health`, `/api/posts`, `/api/cover`, `/api/cron`, `/api/setup`, `/setup`, `/_next`, `/favicon.ico`, `/fonts`. + +> ⚠️ **BUG:** la coincidencia es por **prefijo** (`pathname.startsWith(p)`): `/api/posts` también destapa `/api/posts/[id]` y `/api/posts/[id]/publish`; `/api/cron` destapa `/api/cron/poll`. Consecuencia: crear/editar/borrar/publicar posts y disparar el cron **nunca requieren auth**. --- -## Visual Feedback Loop (`visual_feedback.py`) +## API Propia (route handlers) -- Vision LLM (xiaomi/mimo-v2.5) evalúa legibilidad del texto renderizado por burbuja -- Verdictos: READABLE / TOO_SMALL / OVERFLOWING / EMPTY -- Auto-ajusta `font_size_offset`, `font_size_minimum`, `font_size_maximum` -- Sanity bounds: offset [-20, 50], minimum [6, 60], maximum [25, 100] +| Ruta | Métodos | Auth | Función | +|------|---------|------|---------| +| `/api/auth/login` | POST | pública | Valida password → setea cookie `session` | +| `/api/auth/logout` | POST | pública | Borra cookie | +| `/api/health` | GET | pública | `{status:"ok", uptime}` — healthcheck | +| `/api/cover/[gid]` | GET | pública | Sirve portada cacheada (`Cache-Control: public, max-age=86400`); si falta, self-cachea desde `/api/proxy/galleries/{gid}/cover`; 404 si no hay | +| `/api/cron/poll` | GET | pública (bug) | Invoca `pollOnce()` → `{ok, newPosts}` | +| `/api/posts` | GET/POST | pública (bug) | GET: lista posts (`?published=1` filtra); POST: crea (409 si el gid existe) | +| `/api/posts/[id]` | GET/PATCH/DELETE | pública (bug) | GET detalle; PATCH `updatePost` (⚠️ SQLi); DELETE borra + cover | +| `/api/posts/[id]/publish` | POST | pública (bug) | `{publish: bool}` → `publishPost` | +| `/api/proxy/[...path]` | GET/POST/PUT/DELETE/PATCH | protegida* | Proxy inverso al pipeline (`API_BASE_URL` + `/` + subpath) | +| `/api/setup` | GET/POST | pública | GET: `{configured}`; POST: guarda allowlist `["API_BASE_URL","API_KEY","WEB_PASSWORD","WEBHOOK_SECRET"]` en `data/settings.json` ⚠️ sin auth | +| `/api/webhooks/manga` | POST | firma HMAC* | Crea/actualiza post desde el pipeline + cachea cover | + +\* cuando `WEB_PASSWORD`/`WEBHOOK_SECRET` están configurados; si no, abiertos. --- -## Windows Encoding +## Proxy (`src/app/api/proxy/[...path]/route.ts`) -Set `PYTHONUTF8=1` o `sys.stdin.reconfigure(encoding='utf-8')` para evitar problemas cp1252. Sin esto, `asyncio.create_subprocess_exec` con `PIPE` puede corromper output de manga-translator en Windows. +- Target: `API_BASE = process.env.API_BASE_URL || "http://127.0.0.1:8080/api/v1"`. +- Autentica upstream con `X-API-Key: process.env.API_KEY` si está set. +- Mapeo: `/api/proxy/galleries/123?x=1` → `{API_BASE}/galleries/123?x=1` (query string se pasa tal cual). +- Reenvía body (blob) solo en métodos no-GET/HEAD; copia `Content-Type` de vuelta; descarta `content-encoding/content-length/transfer-encoding`. +- Errores upstream → `502 {error:{code:"proxy_error", message}}`. +- `GET /api/proxy/health` público (allowlist); el resto protegido por sesión (si `WEB_PASSWORD` set). --- -## Known Bugs & Design Issues +## Poller (`src/lib/poller.ts` + `src/instrumentation.ts`) -1. `scraper.py` dead code: `fetch_gallery_links_httpx()` raises `NotImplementedError` — nunca se llama -2. `scraper.py` dead parameter: `should_download()` acepta `translation_enabled` pero nunca lo usa -3. `scraper.py` code duplication: `_fetch_gallery_httpx()` y `_fetch_gallery_sync()` tienen lógica de parsing casi idéntica -4. Dual state tracking: SQLite (`NhentaiStateDB`/`HentaiStateDB`) Y GalleryQueue/UrlQueue trackean estado de galleries sin reconciliación cruzada -5. `process.py` coexiste con `artifacts.py` — versión anterior del pipeline -6. `bot.py` (legacy, 26KB) coexiste con `bot_artifacts.py` (producción, ~30KB) -7. Hardcoded paths: `docker exec -u 33 nextcloud` en `nextcloud_uploader.py` -8. No centralized rate limiting: cada scraper maneja su propio rate limit -9. `mark_processing` en `base_state.py` NO incrementa `retry_count` (se hace por separado) -10. `url_queue.py` dead code: `_build_work_key_for_item` tiene un `return` inalcanzable después de un docstring duplicado de `reload_from_disk` -11. API REST no tiene autenticación por defecto en dev (`api_key` vacío = sin auth) -12. `_render_*.py` y `_smart_render_*.py` y `_vision_feedback*.py` son experimentales sin entry point — coexisten como archivos muertos en la raíz -13. `src/converter.py` usa `subprocess` para `ebook-convert` y `fetch-ebook-metadata` de Calibre — puede fallar si no está instalado +- `POLL_INTERVAL_MS = 60_000` — corre cada 1 minuto (más una vez al arranque). +- Arranque: `instrumentation.ts` → `register()` (solo `NEXT_RUNTIME === "nodejs"`) llama `initSettings()` + `startPoller()`. +- **`pollOnce()`:** + 1. Lista `api.galleries.list("completed", 1, 100)` + `api.galleries.translated()` del pipeline. + 2. Por cada gid: `api.galleries.summary(gid)`. + 3. `cleanTitle()` + `slugify(cleanT, gid)`; sintetiza summary (`synopsis` o `generateSummary()`). + 4. **Auto-publica todo:** no existe → `createPost({..., published: 1})`; existe → `updatePost`. + 5. `cacheCover(gid, "http://127.0.0.1:{PORT||3000}/api/proxy/galleries/{gid}/cover")`. + +> ⚠️ **BLOQUEANTE CONOCIDO:** el endpoint `/galleries` del pipeline solo lista las galleries del UrlQueue activo (~6), no las traducidas en `output/artifacts/` (~38 invisibles). El poller no puede descubrirlas. Solución: que `/galleries` escanee `output/artifacts/` o use `/galleries/translated`. + +> ⚠️ **BUG self-fetch:** `cacheCover` fetchea `/api/proxy/...` (protegido por middleware); sin cookie de servidor, con `WEB_PASSWORD` set el fetch recibe redirect 307 a `/login` y **guarda HTML de login como portada**. + +--- + +## Webhook (`src/app/api/webhooks/manga/route.ts`) + +- **Solo POST.** Firma HMAC-SHA256 del raw body; header `x-signature-256` o `X-Signature-256`. Secret: `WEBHOOK_SECRET || FANSUB_WEBHOOK_SECRET`. **Sin secret configurado → verificación omitida, endpoint abierto.** +- **Payload:** `gid` (req), `title` (req), `tags[]`, `num_pages|pages`, `synopsis`, `url`, `source`, `title_jpn`, `artist`, `parody`, `cover_url`. +- **Acción:** no existe → `createPost({..., published: 1})`; existe → `updatePost`. Luego `cacheCover(...)` (mismo bug de self-fetch). +- Respuesta `{success, gid, post}`; 401 firma mala; 400 sin gid/title. + +--- + +## Caché de Portadas (`src/lib/cover-cache.ts`) + +- Storage: `process.env.COVERS_DIR || data/covers/`. Archivo por gid: `{gid}.{ext}` (sin hash). +- `cacheCover(gid, proxyUrl)`: fetch con timeout 15s → buffer a disco; falla silenciosa (`null`). +- `getCoverPath(gid)`: `readdirSync().filter(f => f.startsWith(gid + "."))`. `getCoverContentType`, `deleteCover`. + +--- + +## CBZ (`src/lib/cbz.ts`) — solo cliente + +- `loadPagesFromCbz(url, signal?)` → `{pages, pageCount}`: fetch → JSZip → filtra imágenes (`.jpg/.jpeg/.png/.webp/.gif/.bmp`) → ordena por primer número en el nombre → `URL.createObjectURL` por página. +- `revokePageUrls(urls)` libera los object URLs. +- No hay lectura de CBZ en servidor ni endpoints de páginas reales (plan: `/api/pages/[gid]`). + +--- + +## Frontend — Grupos y Páginas + +### `(public)` — server-side, sin login (lee SQLite directo) +- `/` — grid de posts publicados (`getAllPosts(true)`), `PostCard`, `grid-cols-2 sm:3 md:4 xl:5`. +- `/p/[slug]` — detalle de post: portada `/api/cover/{gid}`, título + `title_jpn`, badge "Antología/Tomo" si `isAnthology` (tags anthology/compilation/tankoubon o `num_pages >= 100`), `TagBadge` (artist/parody → `/tag/...`), sinopsis solo si `isRealSynopsis` (no empieza con `**`/`Fuente:` y >50 chars), "Más publicaciones" (4 compact). Botones: Leer Manga (`/gallery/[gid]/read` — **privado**), Ver en nhentai/e-hentai, Archivos (desktop). +- `/tag/[tag]` — posts por tag (`getPostsByTag`), `decodeURIComponent(tag)` ⚠️ sin try/catch. + +### `(app)` — client-side, login requerido (SWR → `/api/proxy/*`) +- `/feed` — galleries del pipeline (`useGalleries(status, 1)` → SWR `/api/proxy/galleries?page=1&per_page=50&status=...`, refresh 30s/10s), `FilterBar` + `GalleryGrid` + skeletons. +- `/gallery/[gid]` — detalle: SWR `/summary` + `/artifacts`, `CoverImage`, tags, badge ESP, lista de archivos colapsable, botones Leer/Original/Eliminar. +- `/gallery/[gid]/read` — **lector**: modos `paged` (zoom 0.5–3, flechas, slider, tap zones, swipe >40px, precarga 2 págs) y `webtoon` (scroll continuo, lazy desde 5ª); controles auto-ocultos 3.5s; fullscreen (`f`); keyboard `←→`, `+/-/0`; cascada de fallback para páginas: `/pages` → `/summary` (num_pages) → `/artifacts` → HEAD `/cover` → error. Root `fixed inset-0 z-50 bg-[#09090b] text-white touch-none`. +- `/queue` — estado pipeline: SWR `/api/proxy/queue?status=...&per_page=100` + `/api/proxy/status` (refresh 10s), `SlotGauge`, `StatBox`, botón Retry. +- `/search` — `SearchForm`: POST `/api/proxy/search` `{query, source}` → grid con estado `blocked`; "Encolar" / "Encolar todo (N)" → POST `/api/proxy/galleries`. +- `/submit` — `SubmitForm`: textarea URLs → POST `/api/proxy/galleries` por línea. +- `/admin/posts` — CRUD posts: toggle publicar (POST `/api/posts/{id}/publish`), eliminar (confirm), links a `/p/{slug}` y `/gallery/{gid}`; editor `[id]`: PATCH `{title, summary, slug}`. +- Layout: `Sidebar` desktop fija (`w-56`, 5 links + Salir) + bottom-nav móvil (`h-14`, sin Salir); logo apunta a `/` (público). + +### Standalone +- `/login` — password → POST `/api/auth/login` → `/feed`. +- `/setup` — primer arranque: GET `/api/setup` → si `configured` va a `/feed`; si no, 4 campos (`API_BASE_URL`, `API_KEY`, `WEB_PASSWORD` min 6, `WEBHOOK_SECRET`) → POST `/api/setup`. + +### Hooks +`useGalleries(status, page)` (usado), `useGallery` (muerto), `useQueue`/`useQueueStats` (muertos), `useStatus`/`useSystemStatus` (muerto). QueuePage y GalleryDetailPage reimplementan SWR inline con fetcher duplicado. + +--- + +## Estilos (Tailwind CSS 4) + +- `@import "tailwindcss" source("../../src")` en `globals.css`. Sin CSS modules. +- **Tema oscuro exclusivo** (`html { color-scheme: dark }`): `--background:#09090b`, `--surface:#18181b`, `--border:#27272a`, `--foreground:#fafafa`, `--muted:#a1a1aa`, `--accent:#6366f1` (indigo), `--success:#22c55e`, `--warning:#eab308`, `--error:#ef4444` (+ variantes `-subtle`), `--radius:.5rem`. +- `@utility` custom: `card`, `card-hover`, `tag-pill`, `btn`, `btn-primary`, `btn-ghost`, `btn-danger`, `input`, `skeleton` (+ shimmer). +- Fuente **Inter** (`@font-face` woff2 de fonts.gstatic.com, fallback system-ui). Iconos lucide-react. Scrollbars finos. +- Hardcodeos menores: lector `bg-[#09090b]` fijo; badge ESP emerald fijo. + +--- + +## Variables de Entorno (`.env` / `.env.example`) + +| Variable | Default | Propósito | +|----------|---------|-----------| +| `API_BASE_URL` | `http://host.docker.internal:8080/api/v1` | Base URL del pipeline REST (bare-metal: `127.0.0.1`) | +| `API_KEY` | *(vacío)* | API key del pipeline (vacío = sin auth upstream) | +| `WEB_PASSWORD` | *(vacío)* | Password del panel admin (vacío = todo abierto) | +| `WEBHOOK_SECRET` | *(vacío)* | Validación HMAC del webhook | +| `DB_PATH` | `data/worst-scan.db` | Ruta SQLite | +| `COVERS_DIR` | `data/covers` | Directorio de portadas | +| `JWT_SECRET` | — | (usado por auth.ts si existe; si no, cae a `WEB_PASSWORD`) | +| `PORT` | 3000 | Puerto del server (Dockerfile/systemd) | +| `HOSTNAME` | 0.0.0.0 | Bind (Docker/systemd) | + +--- + +## Despliegue + +**Dos estrategias documentadas, MUTUAMENTE EXCLUYENTES:** + +### A) Docker (agy.md — la real en producción) +- Multi-stage Node 22-alpine: `deps` (python3/make/g++ para better-sqlite3 + `npm ci`) → `builder` (`npm run build`) → `runner` (usuario `nextjs` uid 1001, `EXPOSE 3000`, `ENV PORT=3000 HOSTNAME=0.0.0.0 DB_PATH=/app/data/worst-scan.db COVERS_DIR=/app/data/covers`, `VOLUME /app/data`, HEALTHCHECK `wget /api/health`). +- Compose: `expose: 3000` (sin mapeo), `env_file: .env`, `extra_hosts: host.docker.internal:host-gateway`, red `caddy` (external), volumen `data:/app/data`, `restart: unless-stopped`. +- Caddy en contenedor `caddy-ingress` con auto-HTTPS para `manga.cbcren.online`; recarga: `docker exec caddy-ingress caddy reload --config /etc/caddy/Caddyfile`. Caddyfile canónico del host: `/home/ren/immich/Caddyfile`. + +### B) systemd + Node directo (`install.sh` / `deploy/setup.sh`) +- Instala Node 22 + Caddy, `npm run build`, corre `.next/standalone/server.js` bajo systemd (`Type=exec`, `Restart=always`, `HOSTNAME=127.0.0.1`, `PORT=3000`). +- ⚠️ `install.sh` **desinstala Docker** a propósito — no conviven con la estrategia A. +- ⚠️ `deploy/setup.sh` escribe `WorkingDirectory=$APP_DIR/standalone` (falta `.next/`). +- ⚠️ **`deploy/setup.sh:22` contiene la contraseña de sudo hardcodeada en texto plano** — CRÍTICO, parametrizar. + +### Scripts +- `npm start` → `scripts/start.js`: sincroniza `.next/static` → standalone, `NODE_ENV=production`, spawn `node server.js` con `PORT`/`HOSTNAME`. +- `install.sh`: swap 1GB si <512MB → git/curl → **Docker cleanup** → Node 22 (nodesource) → mirror npm si China → Caddy (cloudsmith) → clone `gitea.cbcren.online/renato97/worst-scan-web` (reset --hard) → `.env` (nunca sobreescribe) → build → systemd unit → bloque Caddy `:80` → health wait → resumen con `http://IP/setup`. + +--- + +## Bugs Conocidos & Deuda Técnica + +### 🔴 Seguridad +1. **SQL injection en `updatePost`** (`db.ts:148-170`) — nombres de columna del body interpolados sin whitelist; permite sobrescribir `published`/`published_at` y columnas arbitrarias. +2. **Middleware por prefijo** (`middleware.ts:23`) — `/api/posts*` y `/api/cron*` públicos en la práctica (PATCH/DELETE/publish/cron sin auth). +3. **Sin `WEB_PASSWORD` → app 100% abierta**, incluidas rutas admin. +4. **`/api/setup` sin auth** — cualquiera configura la app y fija secrets (guardados en texto plano en `data/settings.json`). +5. **Webhook sin `WEBHOOK_SECRET` → abierto** (crear/modificar posts arbitrarios). +6. **JWT fallback predecible** (`worst-scan-web-dev-secret`); si se usa `WEB_PASSWORD` como clave, quien la conozca forja tokens. +7. **Password de sudo hardcodeada** en `deploy/setup.sh:22`. +8. Logout no revoca el JWT (solo expira); sin refresh/rotación. + +### 🐛 Funcionales +9. **~38 galerías invisibles** — `/galleries` del pipeline solo lista las del UrlQueue (~6); el poller no descubre las traducidas. Bloqueante para la web. +10. **Cover self-cache rota con auth** — fetch de servidor sin cookie recibe 307 → guarda HTML de login como portada. +11. **`createPost` publica todo por defecto** — no hay flujo de borrador real. +12. `getGidsNotInPosts()` es un **stub** (`return []`). +13. `ALTER TABLE posts ADD COLUMN url` en `migrate()` es **código muerto** (columna ya existe → siempre lanza y se traga). +14. **Enlaces rotos público→privado**: "Buscar" (`/search`) y "Leer Manga" (`/gallery/[gid]/read`) llevan a rutas con login. +15. **Bug de conteo en `SubmitForm`** — `"X de Y ok"` inflado (cuenta todo menos `"?"`); criterio de fila inconsistente con el contador. +16. **Hooks muertos**: `useGallery`, `useQueue`, `useQueueStats`, `useStatus` — sin uso; fetcher SWR duplicado en 6 archivos. +17. **Overlap bottom-nav móvil** — el layout `(app)` no compensa la nav fija (`p-0` sin `pb-14`). +18. **Logo del sidebar → `/`** (saca al usuario del panel al sitio público); **sin logout en móvil**. +19. **`decodeURIComponent(tag)` sin try/catch** → 500 con `%` literal en el tag. +20. **Sin paginación** — `per_page=50/100` fijos, sin UI de página pese a que la API devuelve `meta`. +21. Lector: zoom solo desktop; tap zones inertes en webtoon; `touch-none`+`touch-pan-y` pueden trabar scroll; título `g/{gid}` si falla summary. +22. `window.location.href = "/feed"` tras eliminar (recarga completa); errores silenciosos en editor de posts, retry y search. + +### 🟡 Docs / Infra +23. `README.md` es boilerplate de create-next-app; `AGENTS.md` (viejo) documentaba el pipeline Python que no está en este repo — esta versión lo reemplaza. +24. Docker vs systemd contradictorios (`install.sh` desinstala Docker). +25. `.env.example` con paths Docker (`/app/data`, `host.docker.internal`) que en bare-metal hay que sobreescribir. +26. CORS `*` en la API del pipeline (lockear en producción). +27. `dominios-disponibles.md` desactualizado (`manga.cbcren.online` figura libre). + +--- + +## Roadmap / Plan Pendiente (de `agy.md`, 7 pasos) + +1. Columnas `pages_count`, `mobi_stored`, `cbz_path` en `posts`. +2. Nuevo `src/lib/gallery-store.ts` (downloadAndExtractCbz con JSZip, downloadMobi, getPagePath…). +3. Rewrite del poller (que descubra galleries traducidas). +4. Endpoints `/api/pages/*` y `/api/download/*/mobi`. +5. Mover lector a grupo `(reader)/` (fuera del layout admin). +6. 6 UI fixes (enlaces públicos, paginación, hooks, bottom-nav…). +7. Cover fallback a `pages/001.jpg`. --- ## What NOT to Add -- No CI/CD pipelines, no pre-commit hooks, no linter/formatter configs -- No WebDAV endpoints (replaced by local filesystem copy) -- No new entry points without discussion +- No CI/CD pipelines, no pre-commit hooks, no linter/formatter configs nuevos (seguir el espíritu del repo). +- No escribir raw SQL fuera de `db.ts`. +- No exponer secrets en texto plano (ni en scripts ni en `settings.json`). +- No nuevos entry points sin discusión previa. diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c..b5bf577 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,12 @@ +# CLAUDE.md + @AGENTS.md +@agy.md + +## Todo agente que trabaje en este repo debe: + +- Leer **AGENTS.md** (documentación viva de la arquitectura, schema, API, bugs) y **agy.md** (post-mortem y plan original) **antes de tocar código**. +- Trabajar SOLO sobre el repo actual (Next.js web). El backend/pipeline Python vive en otro repo — **no editar ni asumir que está acá**. +- Escribir SQL solo dentro de `src/lib/db.ts` (nunca raw en rutas). +- No exponer secrets en texto plano (ni en scripts ni en `data/settings.json`). +- Correr `npm run build` para verificar cambios antes de dar por terminado. \ No newline at end of file diff --git a/deploy/setup.sh b/deploy/setup.sh index 35cdc2b..db32a3e 100644 --- a/deploy/setup.sh +++ b/deploy/setup.sh @@ -18,8 +18,10 @@ err() { printf "${RED}✗${NC} %s\n" "$1"; } info() { printf "${CYAN}→${NC} %s\n" "$1"; } # ── Sudo ────────────────────────────────────── +# Valida credenciales una vez; las siguientes llamadas usan el sudo cacheado, +# sin exponer la contraseña en texto plano en el script. sudo -v || (err "sudo required" && exit 1) -SU() { echo 'Wlillidan1.' | sudo -S "$@"; } +SU() { sudo "$@"; } # ── Swap (prevent OOM during compile) ───────── if [ "$(free -m | awk '/^Swap:/{print $2}')" -lt 512 ]; then diff --git a/scripts/start.js b/scripts/start.js index 8ba48c0..7b1035c 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -13,10 +13,9 @@ if (!fs.existsSync(standaloneDir)) { process.exit(1) } -if (!fs.existsSync(staticDst)) { - console.log("Copying static files to standalone output...") - fs.cpSync(staticSrc, staticDst, { recursive: true }) -} +fs.rmSync(staticDst, { recursive: true, force: true }) +fs.cpSync(staticSrc, staticDst, { recursive: true }) +console.log("Synced static files to standalone output...") const server = path.join(standaloneDir, "server.js") if (!fs.existsSync(server)) { diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx index eb87a65..ca88410 100644 --- a/src/app/(public)/layout.tsx +++ b/src/app/(public)/layout.tsx @@ -1,5 +1,5 @@ import Link from "next/link" -import { Image, Search, Shield } from "lucide-react" +import { Image, Search } from "lucide-react" export default function PublicLayout({ children }: { children: React.ReactNode }) { return ( @@ -20,13 +20,6 @@ export default function PublicLayout({ children }: { children: React.ReactNode } Buscar - - - Admin - diff --git a/src/app/api/cover/[gid]/route.ts b/src/app/api/cover/[gid]/route.ts index 0902016..7e4f74d 100644 --- a/src/app/api/cover/[gid]/route.ts +++ b/src/app/api/cover/[gid]/route.ts @@ -12,8 +12,7 @@ export async function GET( if (!coverPath) { try { - const proxyUrl = `http://127.0.0.1:${process.env.PORT || 3000}/api/proxy/galleries/${gid}/cover` - coverPath = await cacheCover(gid, proxyUrl) + coverPath = await cacheCover(gid) } catch { } } diff --git a/src/app/api/webhooks/manga/route.ts b/src/app/api/webhooks/manga/route.ts index d216906..0cafcee 100644 --- a/src/app/api/webhooks/manga/route.ts +++ b/src/app/api/webhooks/manga/route.ts @@ -75,9 +75,7 @@ export async function POST(req: NextRequest) { })! } - const port = process.env.PORT || "3000" - const proxyUrl = `http://127.0.0.1:${port}/api/proxy/galleries/${gid}/cover` - await cacheCover(gid, proxyUrl) + await cacheCover(gid) return Response.json({ success: true, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7363d4a..44789aa 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,7 +1,11 @@ import { SignJWT, jwtVerify } from "jose" +import crypto from "crypto" +// Secreto de firma: JWT_SECRET explícito > WEB_PASSWORD > secreto efímero +// aleatorio (modo dev sin contraseña — las sesiones no sobreviven restart, +// pero no hay nada hardcodeado ni predecible en el código). const SECRET = new TextEncoder().encode( - process.env.JWT_SECRET || process.env.WEB_PASSWORD || "worst-scan-web-dev-secret", + process.env.JWT_SECRET || process.env.WEB_PASSWORD || crypto.randomBytes(32).toString("hex"), ) const COOKIE_NAME = "session" diff --git a/src/lib/cover-cache.ts b/src/lib/cover-cache.ts index 184e047..e83c17f 100644 --- a/src/lib/cover-cache.ts +++ b/src/lib/cover-cache.ts @@ -1,5 +1,6 @@ import fs from "fs" import path from "path" +import { get } from "./settings" const COVERS_DIR = process.env.COVERS_DIR || path.join(process.cwd(), "data", "covers") @@ -20,10 +21,21 @@ function extFromContentType(ct: string | null): string { return exts[m[1]] || ".jpg" } -export async function cacheCover(gid: string, proxyUrl: string): Promise { +// Descarga la portada DIRECTAMENTE del upstream (API_BASE_URL), con la misma +// API key que usa el resto de la app. NO pasa por el proxy /api/proxy/* (que el +// middleware protege), evitando el redirect a /login que guardaba HTML como portada. +export async function cacheCover(gid: string): Promise { ensureDir() try { - const res = await fetch(proxyUrl, { signal: AbortSignal.timeout(15000) }) + const apiBase = get("API_BASE_URL", "http://127.0.0.1:8080/api/v1") + const apiKey = get("API_KEY") + const headers: Record = {} + if (apiKey) headers["X-API-Key"] = apiKey + + const res = await fetch(`${apiBase}/galleries/${gid}/cover`, { + headers, + signal: AbortSignal.timeout(15000), + }) if (!res.ok) return null const buffer = Buffer.from(await res.arrayBuffer()) @@ -61,4 +73,4 @@ export function getCoverContentType(gid: string): string { export function deleteCover(gid: string): void { const fp = getCoverPath(gid) if (fp) fs.unlinkSync(fp) -} +} \ No newline at end of file diff --git a/src/lib/db.ts b/src/lib/db.ts index b02c605..9091866 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -145,20 +145,35 @@ export function createPost(input: PostInput): Post { return getPostById(result.lastInsertRowid as number)! } -export function updatePost(id: number, updates: Partial): Post | null { +// Columnas permitidas en PATCH — whitelist estricta para impedir SQLi +// por nombres de columna y el bypass de `published`/columnas internas. +const UPDATE_FIELDS = new Set([ + "title", + "title_jpn", + "artist", + "parody", + "tags", + "num_pages", + "source", + "cover_url", + "url", + "summary", + "slug", +]) + +export function updatePost(id: number, updates: Partial): Post | null { const d = getDb() const fields: string[] = [] const values: Record = { id } for (const [k, v] of Object.entries(updates)) { - if (v !== undefined) { - if (k === "tags") { - fields.push("tags = @tags") - values.tags = JSON.stringify(v) - } else { - fields.push(`${k} = @${k}`) - values[k] = v - } + if (v === undefined || !UPDATE_FIELDS.has(k)) continue + if (k === "tags") { + fields.push("tags = @tags") + values.tags = JSON.stringify(v) + } else { + fields.push(`${k} = @${k}`) + values[k] = v } } diff --git a/src/lib/poller.ts b/src/lib/poller.ts index fe83f93..e707456 100644 --- a/src/lib/poller.ts +++ b/src/lib/poller.ts @@ -89,7 +89,7 @@ export async function pollOnce(): Promise<{ newPosts: number }> { } const port = process.env.PORT || "3000" - await cacheCover(gid, `http://127.0.0.1:${port}/api/proxy/galleries/${gid}/cover`) + await cacheCover(gid) } catch { continue } diff --git a/src/middleware.ts b/src/middleware.ts index b9b76a4..831f2ef 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,30 +1,47 @@ import { NextRequest, NextResponse } from "next/server" import { verifySession } from "@/lib/auth" -const publicPaths = [ +// Rutas públicas EXACTAS (coincidencia por segmento, no por prefijo). +const publicExact = new Set([ "/login", "/api/auth/login", "/api/auth/logout", "/api/health", "/api/proxy/health", - "/api/posts", - "/api/cover", - "/api/cron", - "/api/setup", "/setup", - "/_next", - "/favicon.ico", - "/fonts", +]) + +// Prefijos públicos: /api/cover (imágenes de portada para el sitio público), +// assets de Next y fuentes. Coinciden a nivel de segmento: "/api/cover" NO +// destapa "/api/cover/evil" ni otras rutas. +const publicPrefixes = [ + "/api/cover/", + "/_next/", + "/fonts/", ] +// Páginas públicas del sitio (server-side, sin login). +const publicPages = ["/", "/p/", "/tag/"] + +function isPublicPath(pathname: string, method: string): boolean { + if (publicExact.has(pathname)) return true + if (publicPrefixes.some((p) => pathname.startsWith(p))) return true + if (publicPages.some((p) => pathname.startsWith(p))) return true + + // /api/posts: GET de listado público; mutaciones requieren sesión. + if (pathname === "/api/posts" && method === "GET") return true + + // /api/setup: GET (chequeo de config) público; POST (cambiar secrets) NO. + if (pathname === "/api/setup" && method === "GET") return true + + return false +} + export async function middleware(req: NextRequest) { const { pathname } = req.nextUrl + const method = req.method - const isPublic = publicPaths.some((p) => pathname.startsWith(p)) - if (isPublic) return NextResponse.next() - - const isPublicPage = pathname === "/" || pathname.startsWith("/p/") || pathname.startsWith("/tag/") - if (isPublicPage) return NextResponse.next() + if (isPublicPath(pathname, method)) return NextResponse.next() const webPassword = process.env.WEB_PASSWORD if (!webPassword) return NextResponse.next()