feat: worst-scan fansub web — initial release

Posts engine (SQLite + auto-publisher via poller), public feed with
clickable tags, reader, admin panel, submit/search/queue tools.
BFF proxy to pipeline API. Clean dark design. Docker-ready.
This commit is contained in:
renato97
2026-07-23 15:58:28 -03:00
commit 25449aa5df
71 changed files with 11296 additions and 0 deletions
+922
View File
@@ -0,0 +1,922 @@
# worstscan — Manga Translation Pipeline
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.
---
## 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. │
└───────────────────────────────────────────────────────┘
```
---
## 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] <url>` | ✅ |
| **`cli.py`** | **CLI tool** (11 subcomandos) | `python cli.py <subcommand> [args]` | ✅ |
| `inject_gallery.py` | Inyección directa al pipeline | `python inject_gallery.py <URL1> <URL2> ...` | ❌ 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:
```
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 <url>` | `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
```
---
## Scripts (`scripts/`)
| 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 |
---
## KCC Wrapper (`src/kcc.py`)
- `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`
---
## Visual Feedback Loop (`visual_feedback.py`)
- 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]
---
## Windows Encoding
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.
---
## Known Bugs & Design Issues
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
---
## 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