upd
This commit is contained in:
@@ -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` восстанавливает незавершённые задачи из БД в очередь.
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -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 минут
|
||||||
|
— (пусто) — планировщик отключён
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
186
src/api.py
186
src/api.py
@@ -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:
|
||||||
# Отправляем начальный снимок состояния
|
# Отправляем начальный снимок состояния
|
||||||
|
|||||||
Reference in New Issue
Block a user