upd
This commit is contained in:
@@ -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` восстанавливает незавершённые задачи из БД в очередь.
|
||||
|
||||
27
README.md
27
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 минут
|
||||
— (пусто) — планировщик отключён
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -50,10 +50,40 @@
|
||||
.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; }
|
||||
</style>
|
||||
</head>
|
||||
<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 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">
|
||||
@@ -65,6 +95,7 @@
|
||||
<div class="w-2 h-2 rounded-full bg-gray-600" id="ws-dot"></div>
|
||||
<span id="ws-text">Подключение...</span>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
@@ -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) {
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<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>`;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
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) {
|
||||
|
||||
@@ -9,3 +9,4 @@ fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
websockets==12.0
|
||||
pypdf==4.2.0
|
||||
croniter==3.0.3
|
||||
|
||||
186
src/api.py
186
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:
|
||||
# Вычисляем время до следующего запуска
|
||||
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 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():
|
||||
@@ -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:
|
||||
# Отправляем начальный снимок состояния
|
||||
|
||||
Reference in New Issue
Block a user