This commit is contained in:
2026-04-30 17:45:16 +03:00
parent 77592c9a55
commit 88bf301b60
6 changed files with 477 additions and 28 deletions

View File

@@ -84,6 +84,7 @@ manga/
| Изображения | `Pillow==10.3.0` | Открытие/конвертация для PDF | | Изображения | `Pillow==10.3.0` | Открытие/конвертация для PDF |
| PDF | `img2pdf==0.5.1` (fallback: Pillow) | Склейка изображений в PDF | | PDF | `img2pdf==0.5.1` (fallback: Pillow) | Склейка изображений в PDF |
| EPUB | `ebooklib==0.18` | Сборка EPUB3-файла | | EPUB | `ebooklib==0.18` | Сборка EPUB3-файла |
| Расписание | `croniter==3.0.3` | Парсинг cron-выражений для планировщика |
| Логирование | `loguru==0.7.2` | Удобные форматированные логи | | Логирование | `loguru==0.7.2` | Удобные форматированные логи |
| Фронтенд | Tailwind CSS (CDN) + Vanilla JS | SPA без сборщика | | Фронтенд | Tailwind CSS (CDN) + Vanilla JS | SPA без сборщика |
@@ -368,9 +369,11 @@ ws_manager: ConnectionManager # set активных WebSocket-соедине
#### `update_scheduler()` #### `update_scheduler()`
Через 5 минут после старта, затем каждые `UPDATE_INTERVAL_HOURS` (по умолчанию 6 ч): Через 5 минут после старта, затем по расписанию `UPDATE_SCHEDULE` (cron-синтаксис):
- Вызывает `check_for_updates()` для каждой манги с `auto_update=1`. - Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается.
- При нахождении новых глав — добавляет задачу в очередь с флагом `is_update=True`. - Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него.
- Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**.
- `_run_auto_updates()` — сама логика: вызывает `check_for_updates()` для каждой манги с `auto_update=1`, ставит новые главы в очередь.
#### `_enrich_manga(m, db)` #### `_enrich_manga(m, db)`
@@ -546,3 +549,82 @@ DOMContentLoaded
### Позиции в очереди ### Позиции в очереди
Отображаются на карточке как «Позиция в очереди: N». Обновляются в реальном времени через событие `queue_positions` (не перерендер всего списка, а точечное обновление через `updateMangaRow`). Событие рассылается сервером при каждом изменении состояния очереди. Отображаются на карточке как «Позиция в очереди: 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` восстанавливает незавершённые задачи из БД в очередь.

View File

@@ -103,12 +103,37 @@ output/
--- ---
## Авторизация
Задайте в `docker-compose.yml`:
```yaml
- AUTH_LOGIN=ваш_логин
- AUTH_PASSWORD=ваш_пароль
```
Если оба параметра заданы — интерфейс будет защищён формой входа. Сессия сохраняется в браузере на 30 дней, повторный вход не требуется.
---
## Конфигурация (docker-compose.yml) ## Конфигурация (docker-compose.yml)
| Переменная | Default | Описание | | Переменная | Default | Описание |
|------------|---------|---------| |------------|---------|---------|
| `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно | | `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 минут
— (пусто) — планировщик отключён
```
--- ---

View File

@@ -8,7 +8,14 @@ services:
- ./state:/app/state - ./state:/app/state
environment: environment:
- PYTHONUNBUFFERED=1 - 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: ports:
- "8000:8000" - "8000:8000"
shm_size: "2gb" shm_size: "2gb"

View File

@@ -50,10 +50,40 @@
.pulse-dot { width:8px; height:8px; border-radius:50%; background:#3b82f6; animation:pulse 1.5s infinite; } .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} } @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; } ::-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; }
</style> </style>
</head> </head>
<body class="min-h-screen"> <body class="min-h-screen">
<!-- Login screen -->
<div id="login-screen">
<div class="login-card">
<div class="flex items-center gap-3 mb-8 justify-center">
<span class="text-3xl">📚</span>
<h1 class="text-xl font-bold text-white">Manga Downloader</h1>
</div>
<div class="flex flex-col gap-4">
<div>
<label class="text-xs text-gray-400 mb-1 block">Логин</label>
<input id="login-input" type="text" autocomplete="username"
class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
style="background:#0f1117" placeholder="Логин">
</div>
<div>
<label class="text-xs text-gray-400 mb-1 block">Пароль</label>
<input id="password-input" type="password" autocomplete="current-password"
class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
style="background:#0f1117" placeholder="Пароль">
</div>
<div id="login-error" class="text-sm text-red-400 hidden"></div>
<button id="login-btn" onclick="doLogin()" class="btn-primary w-full mt-2">Войти</button>
</div>
</div>
</div>
<!-- Header --> <!-- Header -->
<header class="border-b border-gray-800 px-6 py-4 flex items-center justify-between sticky top-0 z-50" style="background:#0f1117ee;backdrop-filter:blur(12px)"> <header class="border-b border-gray-800 px-6 py-4 flex items-center justify-between sticky top-0 z-50" style="background:#0f1117ee;backdrop-filter:blur(12px)">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -65,6 +95,7 @@
<div class="w-2 h-2 rounded-full bg-gray-600" id="ws-dot"></div> <div class="w-2 h-2 rounded-full bg-gray-600" id="ws-dot"></div>
<span id="ws-text">Подключение...</span> <span id="ws-text">Подключение...</span>
</div> </div>
<button id="logout-btn" onclick="doLogout()" class="hidden text-xs text-gray-500 hover:text-gray-300 px-3 py-1 rounded-lg transition-colors" style="background:#1e293b">Выйти</button>
</div> </div>
</header> </header>
@@ -252,6 +283,87 @@ const state = {
filter: 'all', 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 ──────────────────────────────── // ── WebSocket ────────────────────────────────
let ws, wsReconnectTimer; let ws, wsReconnectTimer;
@@ -264,11 +376,17 @@ function connectWS() {
document.getElementById('ws-text').textContent = 'Подключено'; document.getElementById('ws-text').textContent = 'Подключено';
clearTimeout(wsReconnectTimer); clearTimeout(wsReconnectTimer);
// Keepalive // 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'; 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 = 'Переподключение...'; document.getElementById('ws-text').textContent = 'Переподключение...';
wsReconnectTimer = setTimeout(connectWS, 3000); wsReconnectTimer = setTimeout(connectWS, 3000);
}; };
@@ -625,6 +743,36 @@ async function checkNow(url) {
await fetch('/api/mangas/check_now?url='+encodeURIComponent(url), {method:'POST'}); 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 ────────────────────────────────────── // ── API ──────────────────────────────────────
async function loadStats() { async function loadStats() {
try { try {
@@ -1046,7 +1194,10 @@ function _rowAuto(m) {
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<span>Авто</span> <span>Авто</span>
${autoOn ? `<button onclick="checkNow('${u}')" class="text-indigo-400 hover:text-indigo-300 text-xs">↻</button>` : ''} <button onclick="event.stopPropagation(); checkNowBtn(this, '${u}')"
title="Проверить новые главы сейчас"
class="text-indigo-400 hover:text-white transition-colors px-1 rounded"
style="line-height:1">↻</button>
</div>`; </div>`;
} }
@@ -1507,21 +1658,36 @@ async function saveRenameFolder() {
} }
// ── Init ───────────────────────────────────── // ── Init ─────────────────────────────────────
async function init() { async function initApp() {
_initDeleteModal(); _initDeleteModal();
await loadStats(); await loadStats();
connectWS(); connectWS();
// Загружаем список манги // Загружаем список манги
try { try {
const r = await fetch('/api/mangas'); const r = await fetch('/api/mangas');
if(r.ok) {
const mangas = await r.json(); const mangas = await r.json();
mangas.forEach(m => { state.mangas[m.url] = m; }); mangas.forEach(m => { state.mangas[m.url] = m; });
renderList(); renderList();
}
} catch(e) {} } catch(e) {}
setInterval(loadStats, 15000); 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) { document.getElementById('modal').addEventListener('click', function(e) {

View File

@@ -9,3 +9,4 @@ fastapi==0.111.0
uvicorn[standard]==0.29.0 uvicorn[standard]==0.29.0
websockets==12.0 websockets==12.0
pypdf==4.2.0 pypdf==4.2.0
croniter==3.0.3

View File

@@ -2,14 +2,18 @@
FastAPI веб-сервер: REST API + WebSocket для мониторинга загрузок манги. FastAPI веб-сервер: REST API + WebSocket для мониторинга загрузок манги.
""" """
import asyncio import asyncio
import hashlib
import hmac as _hmac
import os import os
import re import re
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import List, Optional 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.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from loguru import logger from loguru import logger
@@ -20,8 +24,45 @@ from .exporter import patch_meta, MangaMeta
OUTPUT_DIR = Path("/app/output") OUTPUT_DIR = Path("/app/output")
FRONTEND_DIR = Path("/app/frontend") 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 = 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 менеджер ──────────────────────── # ── WebSocket менеджер ────────────────────────
class ConnectionManager: class ConnectionManager:
@@ -145,16 +186,99 @@ async def startup_event():
db.close() 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(): async def update_scheduler():
"""Периодически проверяет новые главы для манг с auto_update=1.""" """
interval_hours = float(os.getenv("UPDATE_INTERVAL_HOURS", "6")) Планировщик авто-обновлений на основе cron-расписания.
interval_sec = interval_hours * 3600 При любой ошибке — 3 попытки с интервалом 5 мин, затем ждёт следующего слота.
logger.info("Планировщик обновлений: каждые {} ч", interval_hours) Цикл никогда не прерывается.
# Первый запуск — через 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) await asyncio.sleep(300)
while True: while True:
# Вычисляем время до следующего запуска
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() await _run_auto_updates()
await asyncio.sleep(interval_sec) 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(): 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) new_chapters = await check_for_updates(url, on_event=ws_manager.broadcast)
if new_chapters: if new_chapters:
logger.info("Новых глав для {}: {}", url, len(new_chapters)) logger.info("Новых глав для {}: {}", url, len(new_chapters))
# Добавляем в очередь с флагом is_update
db2 = StateDB() db2 = StateDB()
try: try:
status = db2.get_manga(url) status = db2.get_manga(url)
@@ -333,6 +456,47 @@ class AddMangaRequest(BaseModel):
format: str = "cbz" 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") @app.get("/api/mangas")
async def list_mangas(): async def list_mangas():
db = StateDB() db = StateDB()
@@ -857,6 +1021,10 @@ async def global_stats():
@app.websocket("/ws") @app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket): 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) await ws_manager.connect(ws)
try: try:
# Отправляем начальный снимок состояния # Отправляем начальный снимок состояния