From 88bf301b609d345e95e9224b5782adb7427d9e26 Mon Sep 17 00:00:00 2001 From: StenFredd Date: Thu, 30 Apr 2026 17:45:16 +0300 Subject: [PATCH] upd --- ARCHITECTURE.md | 88 +++++++++++++++++++- README.md | 27 ++++++- docker-compose.yml | 9 ++- frontend/index.html | 192 +++++++++++++++++++++++++++++++++++++++++--- requirements.txt | 1 + src/api.py | 188 ++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 477 insertions(+), 28 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 67cd76a..c3abcb6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -84,6 +84,7 @@ manga/ | Изображения | `Pillow==10.3.0` | Открытие/конвертация для PDF | | PDF | `img2pdf==0.5.1` (fallback: Pillow) | Склейка изображений в PDF | | EPUB | `ebooklib==0.18` | Сборка EPUB3-файла | +| Расписание | `croniter==3.0.3` | Парсинг cron-выражений для планировщика | | Логирование | `loguru==0.7.2` | Удобные форматированные логи | | Фронтенд | Tailwind CSS (CDN) + Vanilla JS | SPA без сборщика | @@ -368,9 +369,11 @@ ws_manager: ConnectionManager # set активных WebSocket-соедине #### `update_scheduler()` -Через 5 минут после старта, затем каждые `UPDATE_INTERVAL_HOURS` (по умолчанию 6 ч): -- Вызывает `check_for_updates()` для каждой манги с `auto_update=1`. -- При нахождении новых глав — добавляет задачу в очередь с флагом `is_update=True`. +Через 5 минут после старта, затем по расписанию `UPDATE_SCHEDULE` (cron-синтаксис): +- Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается. +- Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него. +- Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**. +- `_run_auto_updates()` — сама логика: вызывает `check_for_updates()` для каждой манги с `auto_update=1`, ставит новые главы в очередь. #### `_enrich_manga(m, db)` @@ -546,3 +549,82 @@ DOMContentLoaded ### Позиции в очереди Отображаются на карточке как «Позиция в очереди: N». Обновляются в реальном времени через событие `queue_positions` (не перерендер всего списка, а точечное обновление через `updateMangaRow`). Событие рассылается сервером при каждом изменении состояния очереди. + +--- + +## 12. Конфигурация + +### Переменные окружения + +| Переменная | Default | Описание | +|------------|---------|---------| +| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно | +| `UPDATE_SCHEDULE` | — | Расписание авто-проверки (cron-синтаксис). Пример: `0 */6 * * *`. Если пусто — планировщик отключён | +| `UPDATE_INTERVAL_HOURS` | — | Устаревший аналог `UPDATE_SCHEDULE`: число часов → конвертируется в cron автоматически | +| `AUTH_LOGIN` / `AUTH_PASSWORD` | — | Логин и пароль для веб-интерфейса. Если оба заданы — включается авторизация | +| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) | + +### Пути (hardcoded в коде) + +| Константа | Путь | +|-----------|------| +| `OUTPUT_DIR` | `/app/output` | +| `FRONTEND_DIR` | `/app/frontend` | +| `DB_PATH` | `/app/state/progress.db` | +| Лог | `/app/state/manga.log` (ротация 10 МБ) | + +--- + +## 13. Docker-инфраструктура + +### Dockerfile + +``` +FROM mcr.microsoft.com/playwright/python:v1.44.0-jammy + └── Ubuntu 22.04 + Python + все системные зависимости для Chromium + +RUN pip install -r requirements.txt +RUN playwright install chromium --with-deps + +CMD uvicorn src.api:app --host 0.0.0.0 --port 8000 +``` + +### docker-compose.yml + +```yaml +volumes: + - ./output:/app/output # CBZ/PDF/EPUB файлы + - ./state:/app/state # БД и логи + +ports: + - "8000:8000" # Веб-интерфейс + +shm_size: "2gb" # Chromium требует shared memory +environment: + - UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron) + - AUTH_LOGIN=... + - AUTH_PASSWORD=... + +restart: unless-stopped # Автоперезапуск при падении +``` + +### CLI-режим (через compose run) + +```bash +# Скачать мангу без веб-интерфейса +docker compose run --rm --entrypoint "" manga \ + python -m src.cli download https://3.readmanga.ru/magicheskaia_bitva --format cbz + +# Анализ +docker compose run --rm --entrypoint "" manga \ + python -m src.cli analyze https://3.readmanga.ru/magicheskaia_bitva +``` + +### Хранение данных + +После остановки контейнера все данные сохраняются на хосте: +- `./output/` — скачанные файлы. +- `./state/progress.db` — состояние БД (что скачано, что в очереди). +- `./state/manga.log` — логи. + +При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь. diff --git a/README.md b/README.md index 1690ed2..f853092 100644 --- a/README.md +++ b/README.md @@ -103,12 +103,37 @@ output/ --- +## Авторизация + +Задайте в `docker-compose.yml`: + +```yaml +- AUTH_LOGIN=ваш_логин +- AUTH_PASSWORD=ваш_пароль +``` + +Если оба параметра заданы — интерфейс будет защищён формой входа. Сессия сохраняется в браузере на 30 дней, повторный вход не требуется. + +--- + ## Конфигурация (docker-compose.yml) | Переменная | Default | Описание | |------------|---------|---------| | `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно | -| `UPDATE_INTERVAL_HOURS` | `6` | Интервал авто-проверки новых глав (часы) | +| `UPDATE_SCHEDULE` | — | Расписание авто-проверки новых глав (cron-синтаксис). Если пусто — отключено | +| `UPDATE_INTERVAL_HOURS` | — | Устаревший формат: число часов (конвертируется в cron автоматически) | +| `AUTH_LOGIN` / `AUTH_PASSWORD` | — | Логин и пароль для веб-интерфейса | + +### Примеры расписания (`UPDATE_SCHEDULE`) + +``` +0 */6 * * * — каждые 6 часов +0 3 * * * — каждый день в 03:00 UTC +0 3 * * MON — каждый понедельник в 03:00 +*/30 * * * * — каждые 30 минут + — (пусто) — планировщик отключён +``` --- diff --git a/docker-compose.yml b/docker-compose.yml index 34ac73b..7b5a448 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,14 @@ services: - ./state:/app/state environment: - PYTHONUNBUFFERED=1 - - UPDATE_INTERVAL_HOURS=6 + # Расписание авто-проверки новых глав (cron-синтаксис). + # Примеры: "0 */6 * * *" — каждые 6 ч | "0 3 * * *" — каждый день в 03:00 + # Оставьте пустым чтобы отключить планировщик. + # Устаревший формат UPDATE_INTERVAL_HOURS=6 тоже поддерживается. + - UPDATE_SCHEDULE=0 */6 * * * + # Авторизация (оба параметра должны быть заданы чтобы включить защиту) + - AUTH_LOGIN=StenFredd + - AUTH_PASSWORD=111111 ports: - "8000:8000" shm_size: "2gb" diff --git a/frontend/index.html b/frontend/index.html index f19c438..1a80227 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -50,22 +50,53 @@ .pulse-dot { width:8px; height:8px; border-radius:50%; background:#3b82f6; animation:pulse 1.5s infinite; } @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} } ::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:#0f1117; } ::-webkit-scrollbar-thumb { background:#2d3148; border-radius:3px; } + /* Login screen */ + #login-screen { position:fixed; inset:0; z-index:9999; background:#0f1117; display:flex; align-items:center; justify-content:center; } + #login-screen.hidden { display:none; } + .login-card { background:#1a1d2e; border:1px solid #2d3148; border-radius:16px; padding:40px; width:100%; max-width:380px; } + +
+ +
+
📚

Manga Downloader

-
-
-
- Подключение... +
+
+
+ Подключение... +
+
-
@@ -252,6 +283,87 @@ const state = { filter: 'all', }; +// ── Auth ───────────────────────────────────── +function showLoginScreen() { + document.getElementById('login-screen').classList.remove('hidden'); + document.getElementById('logout-btn').classList.add('hidden'); + // Закрываем WS если открыт + if(ws) { try { ws.close(); } catch(_){} ws = null; } +} + +function hideLoginScreen() { + document.getElementById('login-screen').classList.add('hidden'); + document.getElementById('logout-btn').classList.remove('hidden'); +} + +async function checkAuth() { + try { + const r = await fetch('/api/auth/check'); + const data = await r.json(); + if(!data.auth_enabled) { hideLoginScreen(); return true; } + if(data.authenticated) { hideLoginScreen(); return true; } + showLoginScreen(); + return false; + } catch(e) { + showLoginScreen(); + return false; + } +} + +async function doLogin() { + const btn = document.getElementById('login-btn'); + const err = document.getElementById('login-error'); + const login = document.getElementById('login-input').value.trim(); + const password = document.getElementById('password-input').value; + err.classList.add('hidden'); + btn.disabled = true; btn.textContent = 'Входим...'; + try { + const r = await fetch('/api/login', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({login, password}), + }); + if(!r.ok) { + const d = await r.json().catch(()=>({})); + err.textContent = d.detail || 'Неверный логин или пароль'; + err.classList.remove('hidden'); + return; + } + hideLoginScreen(); + await initApp(); + } catch(e) { + err.textContent = 'Ошибка сети'; + err.classList.remove('hidden'); + } finally { + btn.disabled = false; btn.textContent = 'Войти'; + } +} + +async function doLogout() { + await fetch('/api/logout', {method:'POST'}).catch(()=>{}); + showLoginScreen(); + document.getElementById('login-input').value = ''; + document.getElementById('password-input').value = ''; + // Сбрасываем состояние + Object.keys(state.mangas).forEach(k => delete state.mangas[k]); + Object.keys(state.chapters).forEach(k => delete state.chapters[k]); + document.getElementById('stats-row').innerHTML = ''; + document.getElementById('manga-list').innerHTML = ''; +} + +// Глобальный перехват 401 +const _origFetch = window.fetch; +window.fetch = async function(...args) { + const r = await _origFetch.apply(this, args); + if(r.status === 401) { + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''; + if(!url.includes('/api/auth/check') && !url.includes('/api/login')) { + showLoginScreen(); + } + } + return r; +}; + // ── WebSocket ──────────────────────────────── let ws, wsReconnectTimer; @@ -264,11 +376,17 @@ function connectWS() { document.getElementById('ws-text').textContent = 'Подключено'; clearTimeout(wsReconnectTimer); // Keepalive - setInterval(() => { if(ws.readyState===1) ws.send('ping'); }, 20000); + setInterval(() => { if(ws && ws.readyState===1) ws.send('ping'); }, 20000); }; - ws.onclose = () => { + ws.onclose = (e) => { document.getElementById('ws-dot').className = 'w-2 h-2 rounded-full bg-red-500'; + if(e.code === 4401) { + // Сессия истекла или не авторизован + document.getElementById('ws-text').textContent = 'Нет доступа'; + showLoginScreen(); + return; + } document.getElementById('ws-text').textContent = 'Переподключение...'; wsReconnectTimer = setTimeout(connectWS, 3000); }; @@ -625,6 +743,36 @@ async function checkNow(url) { await fetch('/api/mangas/check_now?url='+encodeURIComponent(url), {method:'POST'}); } +async function checkNowBtn(btn, url) { + if(btn.disabled) return; + btn.disabled = true; + const orig = btn.textContent; + btn.textContent = '⏳'; + btn.style.color = '#fbbf24'; + try { + const r = await fetch('/api/mangas/check_now?url='+encodeURIComponent(url), {method:'POST'}); + if(r.ok) { + btn.textContent = '✓'; + btn.style.color = '#4ade80'; + setTimeout(() => { + btn.textContent = orig; + btn.style.color = ''; + btn.disabled = false; + }, 2500); + } else { + throw new Error(); + } + } catch { + btn.textContent = '✕'; + btn.style.color = '#f87171'; + setTimeout(() => { + btn.textContent = orig; + btn.style.color = ''; + btn.disabled = false; + }, 2000); + } +} + // ── API ────────────────────────────────────── async function loadStats() { try { @@ -1046,7 +1194,10 @@ function _rowAuto(m) { Авто - ${autoOn ? `` : ''} +
`; } @@ -1507,21 +1658,36 @@ async function saveRenameFolder() { } // ── Init ───────────────────────────────────── -async function init() { +async function initApp() { _initDeleteModal(); await loadStats(); connectWS(); // Загружаем список манги try { const r = await fetch('/api/mangas'); - const mangas = await r.json(); - mangas.forEach(m => { state.mangas[m.url] = m; }); - renderList(); + if(r.ok) { + const mangas = await r.json(); + mangas.forEach(m => { state.mangas[m.url] = m; }); + renderList(); + } } catch(e) {} setInterval(loadStats, 15000); } -document.addEventListener('DOMContentLoaded', init); +async function init() { + const ok = await checkAuth(); + if(ok) await initApp(); +} + +document.addEventListener('DOMContentLoaded', () => { + init(); + // Enter в полях логина + ['login-input','password-input'].forEach(id => { + document.getElementById(id).addEventListener('keydown', e => { + if(e.key === 'Enter') doLogin(); + }); + }); +}); // Закрытие модалки по клику снаружи document.getElementById('modal').addEventListener('click', function(e) { diff --git a/requirements.txt b/requirements.txt index 0166a4a..8301ca5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ fastapi==0.111.0 uvicorn[standard]==0.29.0 websockets==12.0 pypdf==4.2.0 +croniter==3.0.3 diff --git a/src/api.py b/src/api.py index e845495..9aa22f7 100644 --- a/src/api.py +++ b/src/api.py @@ -2,14 +2,18 @@ FastAPI веб-сервер: REST API + WebSocket для мониторинга загрузок манги. """ import asyncio +import hashlib +import hmac as _hmac import os import re +from datetime import datetime, timezone from pathlib import Path from typing import List, Optional -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException +from croniter import croniter +from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect, HTTPException +from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse from pydantic import BaseModel from loguru import logger @@ -20,8 +24,45 @@ from .exporter import patch_meta, MangaMeta OUTPUT_DIR = Path("/app/output") FRONTEND_DIR = Path("/app/frontend") +# ── Авторизация ─────────────────────────────── + +AUTH_LOGIN = os.getenv("AUTH_LOGIN", "") +AUTH_PASSWORD = os.getenv("AUTH_PASSWORD", "") +AUTH_ENABLED = bool(AUTH_LOGIN and AUTH_PASSWORD) + +COOKIE_NAME = "manga_session" +COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 дней + +def _compute_token() -> str: + """Стабильный токен сессии, производный от credentials.""" + return _hmac.new( + AUTH_PASSWORD.encode(), + AUTH_LOGIN.encode(), + hashlib.sha256, + ).hexdigest() + +_VALID_TOKEN: str = _compute_token() if AUTH_ENABLED else "" + +# Пути, доступные без авторизации +_AUTH_EXEMPT = {"/api/login", "/api/auth/check", "/api/logout"} + app = FastAPI(title="Manga Downloader API") + +@app.middleware("http") +async def auth_middleware(request: Request, call_next): + """Проверяет авторизацию для всех /api/* эндпоинтов.""" + if not AUTH_ENABLED: + return await call_next(request) + path = request.url.path + # Пропускаем статику и исключения + if not path.startswith("/api") or path in _AUTH_EXEMPT: + return await call_next(request) + token = request.cookies.get(COOKIE_NAME) + if token != _VALID_TOKEN: + return JSONResponse({"detail": "Unauthorized"}, status_code=401) + return await call_next(request) + # ── WebSocket менеджер ──────────────────────── class ConnectionManager: @@ -145,16 +186,99 @@ async def startup_event(): db.close() +def _parse_schedule() -> Optional[str]: + """ + Читает расписание из переменных окружения. + Приоритет: UPDATE_SCHEDULE (cron-строка) → UPDATE_INTERVAL_HOURS (число часов, legacy). + Возвращает cron-строку или None если планировщик отключён. + """ + schedule = os.getenv("UPDATE_SCHEDULE", "").strip() + if schedule: + # Валидируем cron-выражение + if croniter.is_valid(schedule): + return schedule + logger.error("UPDATE_SCHEDULE='{}' — невалидное cron-выражение, планировщик отключён", schedule) + return None + + # Обратная совместимость: UPDATE_INTERVAL_HOURS → конвертируем в cron + hours_raw = os.getenv("UPDATE_INTERVAL_HOURS", "").strip() + if not hours_raw: + return None + try: + hours = float(hours_raw) + if hours <= 0: + return None + # Конвертируем в cron: каждые N часов (если целое и делит 24) или фиксированное время + h = int(hours) + if h == hours and 24 % h == 0: + cron = f"0 */{h} * * *" + else: + # Нецелое или не делит 24 — берём ближайшее целое число часов + h = max(1, round(hours)) + cron = f"0 */{h} * * *" if 24 % h == 0 else f"0 0/{h} * * *" + logger.info("UPDATE_INTERVAL_HOURS={} → cron: '{}'", hours_raw, cron) + return cron + except ValueError: + logger.error("UPDATE_INTERVAL_HOURS='{}' — не число, планировщик отключён", hours_raw) + return None + + async def update_scheduler(): - """Периодически проверяет новые главы для манг с auto_update=1.""" - interval_hours = float(os.getenv("UPDATE_INTERVAL_HOURS", "6")) - interval_sec = interval_hours * 3600 - logger.info("Планировщик обновлений: каждые {} ч", interval_hours) - # Первый запуск — через 5 минут после старта + """ + Планировщик авто-обновлений на основе cron-расписания. + При любой ошибке — 3 попытки с интервалом 5 мин, затем ждёт следующего слота. + Цикл никогда не прерывается. + """ + cron_expr = _parse_schedule() + if not cron_expr: + logger.info("Планировщик обновлений отключён (UPDATE_SCHEDULE и UPDATE_INTERVAL_HOURS не заданы)") + return + + logger.info("Планировщик обновлений запущен: '{}'", cron_expr) + + # Первый запуск — через 5 минут после старта (не сразу, чтобы не мешать инициализации) await asyncio.sleep(300) + while True: - await _run_auto_updates() - await asyncio.sleep(interval_sec) + # Вычисляем время до следующего запуска + now_utc = datetime.now(timezone.utc) + now_naive = now_utc.replace(tzinfo=None) # croniter работает с naive datetime + cron = croniter(cron_expr, now_naive) + next_run: datetime = cron.get_next(datetime) + wait_sec = max(0.0, (next_run - now_naive).total_seconds()) + + logger.info("Следующая проверка обновлений: {} UTC (через {:.0f} мин)", + next_run.strftime("%Y-%m-%d %H:%M"), wait_sec / 60) + await asyncio.sleep(wait_sec) + + # Запускаем с retry-логикой + await _run_auto_updates_with_retry() + + +async def _run_auto_updates_with_retry(): + """Запускает _run_auto_updates с тремя попытками при ошибке.""" + max_attempts = 3 + retry_delay = 300 # 5 минут между попытками + + for attempt in range(1, max_attempts + 1): + try: + await _run_auto_updates() + return # успех + except asyncio.CancelledError: + raise # не перехватываем отмену + except Exception as e: + if attempt < max_attempts: + logger.warning( + "Авто-обновление: попытка {}/{} завершилась ошибкой: {}. " + "Повтор через {} сек.", attempt, max_attempts, e, retry_delay + ) + await asyncio.sleep(retry_delay) + else: + logger.error( + "Авто-обновление: все {} попытки исчерпаны. " + "Последняя ошибка: {}. Ждём следующего слота по расписанию.", + max_attempts, e + ) async def _run_auto_updates(): @@ -176,7 +300,6 @@ async def _run_auto_updates(): new_chapters = await check_for_updates(url, on_event=ws_manager.broadcast) if new_chapters: logger.info("Новых глав для {}: {}", url, len(new_chapters)) - # Добавляем в очередь с флагом is_update db2 = StateDB() try: status = db2.get_manga(url) @@ -333,6 +456,47 @@ class AddMangaRequest(BaseModel): format: str = "cbz" +# ── Auth API ───────────────────────────────── + +class LoginRequest(BaseModel): + login: str + password: str + + +@app.get("/api/auth/check") +async def auth_check(request: Request): + """Проверить, авторизован ли пользователь.""" + if not AUTH_ENABLED: + return {"authenticated": True, "auth_enabled": False} + ok = request.cookies.get(COOKIE_NAME) == _VALID_TOKEN + return {"authenticated": ok, "auth_enabled": True} + + +@app.post("/api/login") +async def login(body: LoginRequest, response: Response): + if not AUTH_ENABLED: + return {"ok": True} + if body.login != AUTH_LOGIN or body.password != AUTH_PASSWORD: + raise HTTPException(status_code=401, detail="Неверный логин или пароль") + response.set_cookie( + key=COOKIE_NAME, + value=_VALID_TOKEN, + max_age=COOKIE_MAX_AGE, + httponly=True, + samesite="lax", + secure=False, # включите True если HTTPS + ) + return {"ok": True} + + +@app.post("/api/logout") +async def logout(response: Response): + response.delete_cookie(COOKIE_NAME) + return {"ok": True} + + +# ── REST API ────────────────────────────────── + @app.get("/api/mangas") async def list_mangas(): db = StateDB() @@ -857,6 +1021,10 @@ async def global_stats(): @app.websocket("/ws") async def websocket_endpoint(ws: WebSocket): + # Проверяем авторизацию по cookie + if AUTH_ENABLED and ws.cookies.get(COOKIE_NAME) != _VALID_TOKEN: + await ws.close(code=4401) + return await ws_manager.connect(ws) try: # Отправляем начальный снимок состояния