Compare commits
3 Commits
0aa057c991
...
88bf301b60
| Author | SHA1 | Date | |
|---|---|---|---|
| 88bf301b60 | |||
| 77592c9a55 | |||
| 7c5ce807b8 |
136
ARCHITECTURE.md
136
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 без сборщика |
|
||||
|
||||
@@ -249,6 +250,7 @@ class MangaInfo:
|
||||
| `chapters_total` | INTEGER | Кол-во глав (из scraper) |
|
||||
| `chapters_done` | INTEGER | **Стальной счётчик** — не используется в API напрямую¹ |
|
||||
| `last_checked_at` | TEXT | Время последней проверки новых глав |
|
||||
| `folder_name` | TEXT | Кастомное имя папки на диске (NULL → вычисляется из `title_ru`) |
|
||||
|
||||
¹ API всегда пересчитывает `chapters_done` запросом `SELECT COUNT(*) FROM chapters WHERE status='done'`.
|
||||
|
||||
@@ -280,6 +282,8 @@ class MangaInfo:
|
||||
- `chapter_status(chapter_url)` → `str | None`.
|
||||
- `sync_chapters_done(url)` — пересчитывает и сохраняет `chapters_done` из таблицы chapters, возвращает число.
|
||||
- `get_autos()` — манги с `auto_update=1` не в статусе `downloading`.
|
||||
- `update_manga_meta_fields(url, title_ru, title_full)` — обновляет пользовательские метаданные (название), не меняет папку.
|
||||
- `set_folder_name(url, folder_name)` — устанавливает кастомное имя папки.
|
||||
|
||||
---
|
||||
|
||||
@@ -297,13 +301,16 @@ class MangaInfo:
|
||||
3. get_manga_info() → MangaInfo
|
||||
4. update_manga_info() в БД
|
||||
5. upsert_chapter() для каждой главы
|
||||
6. Делим главы:
|
||||
6. Определение папки:
|
||||
├── если db.get_manga(url)["folder_name"] задан → использует его
|
||||
└── иначе → _safe_name(title_ru or title)
|
||||
7. Делим главы:
|
||||
├── to_skip (status == "done" и resume=True)
|
||||
└── to_download (всё остальное)
|
||||
7. Отправляем chapter_skipped события для to_skip
|
||||
8. asyncio.Semaphore(chapter_concurrency)
|
||||
9. asyncio.gather(*[process_chapter(ch) for ch in to_download])
|
||||
10. sync_chapters_done() → update_manga_status → "done"
|
||||
8. Отправляем chapter_skipped события для to_skip
|
||||
9. asyncio.Semaphore(chapter_concurrency)
|
||||
10. asyncio.gather(*[process_chapter(ch) for ch in to_download])
|
||||
11. sync_chapters_done() → update_manga_status → "done"
|
||||
```
|
||||
|
||||
#### `process_chapter(ch)` (внутренняя корутина)
|
||||
@@ -344,6 +351,12 @@ active_tasks: dict[str, asyncio.Task] # url → текущая Task загру
|
||||
ws_manager: ConnectionManager # set активных WebSocket-соединений
|
||||
```
|
||||
|
||||
#### Вспомогательные функции
|
||||
|
||||
- `_safe_name(s)` — транслитерирует строку в безопасное имя папки (strip спецсимволов, пробелы → `_`, max 80 символов).
|
||||
- `_manga_folder(m)` — возвращает `Path` к папке манги: если `m["folder_name"]` задан — использует его, иначе вычисляет из `title` через `_safe_name()`. Используется везде: `_enrich_manga`, `_manga_detail`, `delete_manga`, `rename_folder`.
|
||||
- `_broadcast_queue_positions()` — отправляет всем WS-клиентам событие `queue_positions` с актуальным словарём `{url: позиция}`. Вызывается после любого изменения очереди: старт/конец задачи в воркере, `prioritize`, `stop`, `resume`, `add_to_queue`, `force_redownload`.
|
||||
|
||||
#### Жизненный цикл при старте (`startup_event`)
|
||||
|
||||
1. Запускает `queue_worker()` как фоновую Task.
|
||||
@@ -356,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)`
|
||||
|
||||
@@ -425,6 +440,10 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
||||
| `POST` | `/api/mangas/retry_errors?url=` | Сбросить failed/partial главы → pending |
|
||||
| `POST` | `/api/mangas/auto_update?url=&enabled=` | Вкл/выкл авто-обновление |
|
||||
| `POST` | `/api/mangas/check_now?url=` | Немедленно проверить новые главы |
|
||||
| `POST` | `/api/mangas/update_meta` | Изменить `title_ru`/`title_full` и применить к метаданным файлов `{url, title_ru, title_full}` |
|
||||
| `POST` | `/api/mangas/rename_folder` | Переименовать папку на диске и обновить пути в БД `{url, folder_name}` |
|
||||
| `POST` | `/api/mangas/refresh_meta?url=` | Обновить метаданные в уже скачанных файлах |
|
||||
| `POST` | `/api/mangas/force_redownload?url=` | Сбросить все главы и поставить в очередь заново |
|
||||
| `DELETE` | `/api/mangas?url=` | Удалить мангу из БД |
|
||||
| `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) |
|
||||
| `GET` | `/api/history?limit=&manga_url=` | История событий |
|
||||
@@ -448,6 +467,9 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
||||
| `manga_stopped` | `{url}` | Остановлена |
|
||||
| `manga_prioritized` | `{url, preempted_url}` | Приоритет изменён |
|
||||
| `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления |
|
||||
| `manga_meta_updated` | `{url, title, title_ru, title_full}` | Метаданные отредактированы пользователем |
|
||||
| `manga_folder_renamed` | `{url, folder_name}` | Папка переименована |
|
||||
| `queue_positions` | `{positions: {url: номер}}` | Актуальные позиции в очереди — отправляется при любом изменении очереди |
|
||||
| `chapter_start` | `{url, chapter_url, chapter_number, chapters_done, chapters_total}` | Начало главы |
|
||||
| `chapter_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова |
|
||||
| `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) |
|
||||
@@ -456,6 +478,7 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
||||
| `check_started` / `check_done` | `{url, new_chapters?}` | Проверка обновлений |
|
||||
| `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава |
|
||||
| `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления |
|
||||
| `meta_refreshed` | `{url, updated, failed}` | Метаданные файлов обновлены |
|
||||
|
||||
### Клиент → Сервер
|
||||
|
||||
@@ -467,7 +490,7 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
||||
|
||||
## 9. Фронтенд
|
||||
|
||||
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~1000 строк).
|
||||
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~1500 строк).
|
||||
|
||||
**Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика).
|
||||
|
||||
@@ -506,80 +529,26 @@ DOMContentLoaded
|
||||
|
||||
### Модальное окно детали
|
||||
|
||||
Открывается кликом на строку манги. Загружает `GET /api/mangas/detail?url=` с полным списком глав, файлами на диске, статистикой ошибок.
|
||||
Открывается кликом на строку манги. Загружает `GET /api/mangas/detail?url=` с полным списком глав, файлами на диске, статистикой ошибок. Содержит кнопки:
|
||||
- **✏️ Редактировать название** — открывает модалку изменения `title_ru` / `title_full`. Папка не переименовывается, но метаданные всех файлов обновляются автоматически через `_do_refresh_meta`.
|
||||
- **📁 Переименовать папку** — открывает модалку смены имени папки на диске (доступна при статусе не `downloading`). Физически переименовывает папку, обновляет пути в `chapters.output_*`, сохраняет `folder_name` в `mangas`.
|
||||
- **🏷 Обновить метатеги** — принудительно обновляет метаданные в уже скачанных файлах (для манги в статусе `done`).
|
||||
- **↺ Скачать заново** — сбрасывает все главы и ставит в очередь повторно.
|
||||
|
||||
---
|
||||
### Карточки манги (кнопки)
|
||||
|
||||
## 10. Жизненный цикл загрузки манги
|
||||
| Кнопка | Условие отображения | Действие |
|
||||
|--------|---------------------|---------|
|
||||
| ℹ️ | всегда | Открыть детальное модальное окно |
|
||||
| ⚠️ N | `errors_count > 0` | Открыть вкладку ошибок в модалке |
|
||||
| ⏸ | `status` = `downloading` или `queued` | Остановить загрузку |
|
||||
| ▶ | `status` = `stopped` или `failed` | Возобновить |
|
||||
| 🚀 | `status` = `queued` (только в очереди, не активная) | Переместить в начало очереди |
|
||||
| ✕ | всегда | Удалить |
|
||||
|
||||
```
|
||||
POST /api/queue {urls: ["https://..."]}
|
||||
│
|
||||
├── db.add_manga(url) → status="queued"
|
||||
├── download_queue.put({url, fmt})
|
||||
├── ws_manager.broadcast(manga_queued)
|
||||
└── asyncio.create_task(_fetch_preview(url)) ← быстрый предпросмотр
|
||||
│
|
||||
└── get_manga_info() → ws manga_preview (название, кол-во глав)
|
||||
### Позиции в очереди
|
||||
|
||||
queue_worker() (фоновая Task)
|
||||
│
|
||||
└── job = await download_queue.get()
|
||||
│
|
||||
└── asyncio.create_task(download_manga(url, fmt, ...))
|
||||
│
|
||||
├── status → "downloading"
|
||||
├── get_manga_info() → ws manga_info
|
||||
├── upsert_chapter() × N
|
||||
│
|
||||
├── to_skip → ws chapter_skipped × M
|
||||
│
|
||||
└── asyncio.gather(
|
||||
process_chapter(ch1), ─┐
|
||||
process_chapter(ch2), ├── параллельно
|
||||
... ─┘
|
||||
limit: Semaphore(CHAPTER_CONCURRENCY)
|
||||
)
|
||||
│
|
||||
├── ctx.new_page()
|
||||
├── get_chapter_images_and_download()
|
||||
│ ├── page.route() перехват img
|
||||
│ ├── ArrowRight листание
|
||||
│ └── сохранение байт
|
||||
├── export() → .cbz/.pdf/.epub
|
||||
├── db.mark_done()
|
||||
└── ws chapter_done / chapter_failed
|
||||
|
||||
└── status → "done" / "failed"
|
||||
└── ws manga_done / manga_failed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Параллельная загрузка
|
||||
|
||||
### Параллельность глав
|
||||
|
||||
`CHAPTER_CONCURRENCY` (env, default `3`) — сколько глав загружается одновременно.
|
||||
|
||||
```
|
||||
asyncio.Semaphore(CHAPTER_CONCURRENCY)
|
||||
|
||||
Все N глав запускаются сразу через asyncio.gather(),
|
||||
но одновременно в браузере открыто не более CHAPTER_CONCURRENCY вкладок.
|
||||
```
|
||||
|
||||
Все вкладки работают в **одном** `BrowserContext` — это важно: cookies DDoS-Guard получены при открытии страницы манги и автоматически применяются ко всем вкладкам контекста.
|
||||
|
||||
### Защита от race condition
|
||||
|
||||
1. **Повторная проверка статуса** внутри семафора: если пока ждали семафор другая горутина уже скачала эту главу — пропустить.
|
||||
2. **`db_lock`** — все SQLite-операции сериализованы через `asyncio.Lock()`. `sqlite3` не поддерживает concurrent writes.
|
||||
3. **`counter_lock`** — атомарный инкремент счётчика `chapters_done` для правильных данных в WS-событиях.
|
||||
|
||||
### Параллельность манг
|
||||
|
||||
Манги в очереди обрабатываются **последовательно** (один воркер). Параллельная загрузка нескольких манг одновременно не реализована, чтобы не перегружать сайт и не создавать проблем с памятью Chromium.
|
||||
Отображаются на карточке как «Позиция в очереди: N». Обновляются в реальном времени через событие `queue_positions` (не перерендер всего списка, а точечное обновление через `updateMangaRow`). Событие рассылается сервером при каждом изменении состояния очереди.
|
||||
|
||||
---
|
||||
|
||||
@@ -590,7 +559,9 @@ asyncio.Semaphore(CHAPTER_CONCURRENCY)
|
||||
| Переменная | Default | Описание |
|
||||
|------------|---------|---------|
|
||||
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно |
|
||||
| `UPDATE_INTERVAL_HOURS` | `6` | Интервал авто-проверки новых глав (часы) |
|
||||
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки (cron-синтаксис). Пример: `0 */6 * * *`. Если пусто — планировщик отключён |
|
||||
| `UPDATE_INTERVAL_HOURS` | — | Устаревший аналог `UPDATE_SCHEDULE`: число часов → конвертируется в cron автоматически |
|
||||
| `AUTH_LOGIN` / `AUTH_PASSWORD` | — | Логин и пароль для веб-интерфейса. Если оба заданы — включается авторизация |
|
||||
| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
|
||||
|
||||
### Пути (hardcoded в коде)
|
||||
@@ -630,7 +601,9 @@ ports:
|
||||
|
||||
shm_size: "2gb" # Chromium требует shared memory
|
||||
environment:
|
||||
- UPDATE_INTERVAL_HOURS=6
|
||||
- UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron)
|
||||
- AUTH_LOGIN=...
|
||||
- AUTH_PASSWORD=...
|
||||
|
||||
restart: unless-stopped # Автоперезапуск при падении
|
||||
```
|
||||
@@ -655,4 +628,3 @@ docker compose run --rm --entrypoint "" manga \
|
||||
- `./state/manga.log` — логи.
|
||||
|
||||
При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь.
|
||||
|
||||
|
||||
56
README.md
56
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 минут
|
||||
— (пусто) — планировщик отключён
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -130,4 +155,33 @@ output/
|
||||
|
||||
Для завершённых серий (`pub_status = completed`) в `ComicInfo.xml` записывается поле `<Count>` — Komga отображает прогресс чтения серии.
|
||||
|
||||
---
|
||||
|
||||
## Редактирование метаданных
|
||||
|
||||
Через веб-интерфейс можно изменить название серии, не перекачивая файлы:
|
||||
|
||||
1. Кликните на строку манги → откроется окно деталей.
|
||||
2. Нажмите **✏️ Редактировать название**.
|
||||
3. Измените «Название (ru)» и/или «Полное название».
|
||||
4. Нажмите **Сохранить** — метаданные обновятся автоматически во всех скачанных файлах.
|
||||
|
||||
> **Важно:** папка на диске при этом **не переименовывается**. Чтобы переименовать папку — используйте отдельную функцию ниже.
|
||||
|
||||
---
|
||||
|
||||
## Переименование папки
|
||||
|
||||
Через веб-интерфейс можно изменить имя папки, в которую сохраняются файлы манги:
|
||||
|
||||
1. Кликните на строку манги → откроется окно деталей.
|
||||
2. Нажмите **📁 Переименовать папку**.
|
||||
3. Введите новое имя (спецсимволы удалятся автоматически, пробелы заменятся на `_`).
|
||||
4. Нажмите **Переименовать**.
|
||||
|
||||
После переименования:
|
||||
- Физическая папка на диске будет переименована.
|
||||
- Пути ко всем уже скачанным файлам обновятся в БД.
|
||||
- Дозагрузка новых глав продолжится в переименованную папку.
|
||||
|
||||
|
||||
|
||||
@@ -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,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; }
|
||||
</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">
|
||||
<span class="text-2xl">📚</span>
|
||||
<h1 class="text-xl font-bold text-white">Manga Downloader</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div id="ws-status" class="flex items-center gap-2 text-sm text-gray-400">
|
||||
<div class="w-2 h-2 rounded-full bg-gray-600" id="ws-dot"></div>
|
||||
<span id="ws-text">Подключение...</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<div id="ws-status" class="flex items-center gap-2 text-sm text-gray-400">
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
||||
@@ -192,6 +223,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модалка редактирования метаданных -->
|
||||
<div id="edit-meta-modal" class="fixed inset-0 z-[60] hidden items-center justify-center" style="background:rgba(0,0,0,0.75)">
|
||||
<div class="card rounded-2xl w-full max-w-md mx-4 p-6 flex flex-col gap-4">
|
||||
<h3 class="font-semibold text-white text-base">✏️ Редактировать название</h3>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Название (ru)</label>
|
||||
<input id="edit-meta-title-ru" type="text"
|
||||
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="edit-meta-title-full" type="text"
|
||||
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>
|
||||
<p class="text-xs text-gray-500">⚠️ Папка не переименуется. Метаданные файлов обновятся автоматически.</p>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end mt-2">
|
||||
<button onclick="closeEditMeta()"
|
||||
class="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white"
|
||||
style="background:#1e293b">Отмена</button>
|
||||
<button onclick="saveEditMeta()" id="edit-meta-save-btn"
|
||||
class="px-4 py-2 rounded-lg text-sm font-semibold text-white"
|
||||
style="background:#4f46e5">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модалка переименования папки -->
|
||||
<div id="rename-folder-modal" class="fixed inset-0 z-[60] hidden items-center justify-center" style="background:rgba(0,0,0,0.75)">
|
||||
<div class="card rounded-2xl w-full max-w-md mx-4 p-6 flex flex-col gap-4">
|
||||
<h3 class="font-semibold text-white text-base">📁 Переименовать папку</h3>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Новое имя папки</label>
|
||||
<input id="rename-folder-input" type="text"
|
||||
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="Название папки">
|
||||
<p class="text-xs text-gray-500 mt-2">Специальные символы будут удалены. Пробелы заменятся на «_».</p>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end mt-2">
|
||||
<button onclick="closeRenameFolder()"
|
||||
class="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white"
|
||||
style="background:#1e293b">Отмена</button>
|
||||
<button onclick="saveRenameFolder()" id="rename-folder-save-btn"
|
||||
class="px-4 py-2 rounded-lg text-sm font-semibold text-white"
|
||||
style="background:#4f46e5">Переименовать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── State ────────────────────────────────────
|
||||
const state = {
|
||||
@@ -200,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;
|
||||
|
||||
@@ -212,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);
|
||||
};
|
||||
@@ -377,6 +547,34 @@ function handleEvent(msg) {
|
||||
// Ничего не делаем визуально — файлы обновлены на диске
|
||||
break;
|
||||
|
||||
case 'manga_meta_updated':
|
||||
if(state.mangas[msg.url]) {
|
||||
state.mangas[msg.url].title = msg.title;
|
||||
state.mangas[msg.url].title_ru = msg.title_ru;
|
||||
state.mangas[msg.url].title_full = msg.title_full;
|
||||
updateMangaRow(msg.url);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'manga_folder_renamed':
|
||||
if(state.mangas[msg.url]) {
|
||||
state.mangas[msg.url].folder_name = msg.folder_name;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'queue_positions': {
|
||||
// Обновляем queue_position для всех манг
|
||||
const pos = msg.positions || {};
|
||||
Object.values(state.mangas).forEach(m => {
|
||||
const newPos = pos[m.url] ?? null;
|
||||
if(m.queue_position !== newPos) {
|
||||
m.queue_position = newPos;
|
||||
updateMangaRow(m.url);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'manga_done':
|
||||
if(state.mangas[msg.url]) {
|
||||
state.mangas[msg.url].status = 'done';
|
||||
@@ -545,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 {
|
||||
@@ -948,7 +1176,7 @@ function _rowButtons(m) {
|
||||
${m.status === 'stopped' || m.status === 'failed'
|
||||
? `<button onclick="resumeManga('${u}')" title="Возобновить" style="background:#14532d;color:#86efac;padding:4px 12px;border-radius:6px;font-size:0.75rem;cursor:pointer">▶</button>`
|
||||
: ''}
|
||||
${!isActive
|
||||
${m.status === 'queued'
|
||||
? `<button onclick="prioritizeManga('${u}')" title="Загрузить первой" style="background:#312e81;color:#a5b4fc;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">🚀</button>`
|
||||
: ''}
|
||||
<button onclick="deleteManga('${u}')" class="btn-danger" title="Удалить">✕</button>
|
||||
@@ -966,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>`;
|
||||
}
|
||||
|
||||
@@ -1185,6 +1416,17 @@ function renderModalBody(data) {
|
||||
</details>` : '<div class="text-xs text-gray-500 mb-3">Файлов на диске нет</div>'}
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-800">
|
||||
<button onclick="openEditMeta('${escHtml(data.url)}')"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
||||
style="background:#1e293b;color:#94a3b8;border:1px solid #334155">
|
||||
✏️ Редактировать название
|
||||
</button>
|
||||
${data.status !== 'downloading' ? `
|
||||
<button onclick="openRenameFolder('${escHtml(data.url)}', '${escHtml(data.folder_name || '')}')"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
||||
style="background:#1e293b;color:#94a3b8;border:1px solid #334155">
|
||||
📁 Переименовать папку
|
||||
</button>` : ''}
|
||||
${data.status === 'done' ? `
|
||||
<button id="modal-refresh-meta-btn" onclick="refreshMetaModal('${escHtml(data.url)}')"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
||||
@@ -1323,27 +1565,140 @@ function escHtml(s) {
|
||||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── Edit Meta ────────────────────────────────
|
||||
let _editMetaUrl = null;
|
||||
|
||||
function openEditMeta(url) {
|
||||
_editMetaUrl = url;
|
||||
const m = state.mangas[url] || {};
|
||||
document.getElementById('edit-meta-title-ru').value = m.title_ru || m.title || '';
|
||||
document.getElementById('edit-meta-title-full').value = m.title_full || '';
|
||||
const modal = document.getElementById('edit-meta-modal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
}
|
||||
|
||||
function closeEditMeta() {
|
||||
const modal = document.getElementById('edit-meta-modal');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
_editMetaUrl = null;
|
||||
}
|
||||
|
||||
async function saveEditMeta() {
|
||||
if(!_editMetaUrl) return;
|
||||
const btn = document.getElementById('edit-meta-save-btn');
|
||||
btn.disabled = true; btn.textContent = '⏳ Сохраняем...';
|
||||
const title_ru = document.getElementById('edit-meta-title-ru').value.trim();
|
||||
const title_full = document.getElementById('edit-meta-title-full').value.trim();
|
||||
try {
|
||||
const r = await fetch('/api/mangas/update_meta', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({url: _editMetaUrl, title_ru, title_full}),
|
||||
});
|
||||
if(!r.ok) throw new Error(await r.text());
|
||||
// Update local state
|
||||
if(state.mangas[_editMetaUrl]) {
|
||||
state.mangas[_editMetaUrl].title = title_ru;
|
||||
state.mangas[_editMetaUrl].title_ru = title_ru;
|
||||
state.mangas[_editMetaUrl].title_full = title_full;
|
||||
}
|
||||
renderList();
|
||||
closeEditMeta();
|
||||
} catch(e) {
|
||||
alert('Ошибка: ' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Сохранить';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rename Folder ────────────────────────────
|
||||
let _renameFolderUrl = null;
|
||||
|
||||
function openRenameFolder(url, currentFolder) {
|
||||
_renameFolderUrl = url;
|
||||
const m = state.mangas[url] || {};
|
||||
const cur = currentFolder || m.folder_name || (m.title_ru || m.title || '').replace(/[^\w\s\-]/g,'').trim().replace(/ /g,'_');
|
||||
document.getElementById('rename-folder-input').value = cur;
|
||||
const modal = document.getElementById('rename-folder-modal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
}
|
||||
|
||||
function closeRenameFolder() {
|
||||
const modal = document.getElementById('rename-folder-modal');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
_renameFolderUrl = null;
|
||||
}
|
||||
|
||||
async function saveRenameFolder() {
|
||||
if(!_renameFolderUrl) return;
|
||||
const btn = document.getElementById('rename-folder-save-btn');
|
||||
btn.disabled = true; btn.textContent = '⏳ Переименовываем...';
|
||||
const folder_name = document.getElementById('rename-folder-input').value.trim();
|
||||
try {
|
||||
const r = await fetch('/api/mangas/rename_folder', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({url: _renameFolderUrl, folder_name}),
|
||||
});
|
||||
if(!r.ok) throw new Error((await r.json()).detail || await r.text());
|
||||
const data = await r.json();
|
||||
if(state.mangas[_renameFolderUrl]) {
|
||||
state.mangas[_renameFolderUrl].folder_name = data.folder_name;
|
||||
}
|
||||
closeRenameFolder();
|
||||
} catch(e) {
|
||||
alert('Ошибка: ' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Переименовать';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
if(e.target === this) closeModal();
|
||||
});
|
||||
document.getElementById('edit-meta-modal').addEventListener('click', function(e) {
|
||||
if(e.target === this) closeEditMeta();
|
||||
});
|
||||
document.getElementById('rename-folder-modal').addEventListener('click', function(e) {
|
||||
if(e.target === this) closeRenameFolder();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,3 +9,4 @@ fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
websockets==12.0
|
||||
pypdf==4.2.0
|
||||
croniter==3.0.3
|
||||
|
||||
319
src/api.py
319
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:
|
||||
@@ -55,6 +96,13 @@ download_queue: asyncio.Queue = asyncio.Queue()
|
||||
active_tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
async def _broadcast_queue_positions():
|
||||
"""Отправляет всем клиентам актуальные позиции в очереди."""
|
||||
queue_list = list(download_queue._queue) # type: ignore
|
||||
positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)}
|
||||
await ws_manager.broadcast({"type": "queue_positions", "positions": positions})
|
||||
|
||||
|
||||
async def queue_worker():
|
||||
"""Последовательно обрабатывает очередь загрузок. Перезапускается при краше."""
|
||||
while True:
|
||||
@@ -84,9 +132,12 @@ async def _queue_worker_loop():
|
||||
|
||||
if skip:
|
||||
download_queue.task_done()
|
||||
await _broadcast_queue_positions()
|
||||
continue
|
||||
|
||||
logger.info("Воркер: начинаю скачивать {}", url)
|
||||
# Позиции изменились — уведомляем клиентов
|
||||
await _broadcast_queue_positions()
|
||||
dl_task = asyncio.create_task(download_manga(
|
||||
url=url,
|
||||
fmt=fmt,
|
||||
@@ -116,6 +167,7 @@ async def _queue_worker_loop():
|
||||
finally:
|
||||
active_tasks.pop(url, None)
|
||||
download_queue.task_done()
|
||||
await _broadcast_queue_positions()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
@@ -134,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():
|
||||
@@ -165,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)
|
||||
@@ -186,6 +320,19 @@ async def _run_auto_updates():
|
||||
|
||||
# ── Вспомогательные функции ───────────────────
|
||||
|
||||
def _safe_name(s: str) -> str:
|
||||
return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80]
|
||||
|
||||
|
||||
def _manga_folder(m: dict) -> Path:
|
||||
"""Возвращает папку манги с учётом кастомного имени."""
|
||||
if m.get("folder_name"):
|
||||
return OUTPUT_DIR / m["folder_name"]
|
||||
title = m.get("title") or ""
|
||||
safe_title = _safe_name(title)
|
||||
return OUTPUT_DIR / safe_title
|
||||
|
||||
|
||||
def _dir_size(path: Path) -> int:
|
||||
"""Размер директории в байтах."""
|
||||
if not path.exists():
|
||||
@@ -203,9 +350,7 @@ def _format_size(bytes_val: int) -> str:
|
||||
|
||||
def _enrich_manga(m: dict, db: StateDB) -> dict:
|
||||
"""Обогащает строку манги реальными счётчиками из таблицы chapters."""
|
||||
title = m.get("title") or ""
|
||||
safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80]
|
||||
size_bytes = _dir_size(OUTPUT_DIR / safe_title)
|
||||
size_bytes = _dir_size(_manga_folder(m))
|
||||
ch_done_count = db.conn.execute(
|
||||
"SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'",
|
||||
(m["url"],)
|
||||
@@ -238,9 +383,7 @@ def _manga_detail(manga: dict, db: StateDB) -> dict:
|
||||
chapters = db.get_all_chapters(url)
|
||||
|
||||
# Определяем директорию манги
|
||||
title = manga.get("title") or ""
|
||||
safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80]
|
||||
manga_dir = OUTPUT_DIR / safe_title
|
||||
manga_dir = _manga_folder(manga)
|
||||
size_bytes = _dir_size(manga_dir)
|
||||
|
||||
# Файлы
|
||||
@@ -313,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()
|
||||
@@ -361,6 +545,7 @@ async def add_to_queue(body: AddMangaRequest):
|
||||
"url": url,
|
||||
"format": body.format,
|
||||
})
|
||||
await _broadcast_queue_positions()
|
||||
# Запускаем фоновую задачу предпросмотра (без Chromium — быстро)
|
||||
asyncio.create_task(_fetch_preview(url))
|
||||
else:
|
||||
@@ -525,6 +710,7 @@ async def prioritize_manga(url: str):
|
||||
"url": url,
|
||||
"preempted_url": current_url,
|
||||
})
|
||||
await _broadcast_queue_positions()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -619,6 +805,96 @@ async def _do_refresh_meta(url: str):
|
||||
db.close()
|
||||
|
||||
|
||||
class UpdateMetaRequest(BaseModel):
|
||||
url: str
|
||||
title_ru: str
|
||||
title_full: str = ""
|
||||
|
||||
|
||||
@app.post("/api/mangas/update_meta")
|
||||
async def update_meta(body: UpdateMetaRequest):
|
||||
"""Обновить метаданные манги (название серии) и применить к файлам."""
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(body.url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
db.update_manga_meta_fields(
|
||||
body.url,
|
||||
title_ru=body.title_ru or None,
|
||||
title_full=body.title_full or None,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
# Обновляем метаданные в файлах фоново
|
||||
asyncio.create_task(_do_refresh_meta(body.url))
|
||||
await ws_manager.broadcast({
|
||||
"type": "manga_meta_updated",
|
||||
"url": body.url,
|
||||
"title": body.title_ru,
|
||||
"title_ru": body.title_ru,
|
||||
"title_full": body.title_full,
|
||||
})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class RenameFolderRequest(BaseModel):
|
||||
url: str
|
||||
folder_name: str
|
||||
|
||||
|
||||
@app.post("/api/mangas/rename_folder")
|
||||
async def rename_folder(body: RenameFolderRequest):
|
||||
"""Переименовать папку манги и обновить пути в БД."""
|
||||
new_folder = _safe_name(body.folder_name)
|
||||
if not new_folder:
|
||||
raise HTTPException(status_code=400, detail="Некорректное имя папки")
|
||||
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(body.url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
if manga["status"] == "downloading" and body.url in active_tasks:
|
||||
raise HTTPException(status_code=400, detail="Нельзя переименовать — манга загружается")
|
||||
|
||||
old_dir = _manga_folder(manga)
|
||||
new_dir = OUTPUT_DIR / new_folder
|
||||
|
||||
if old_dir != new_dir:
|
||||
if new_dir.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Папка '{new_folder}' уже существует")
|
||||
if old_dir.exists():
|
||||
import shutil
|
||||
shutil.move(str(old_dir), str(new_dir))
|
||||
logger.info("Папка переименована: {} → {}", old_dir, new_dir)
|
||||
# Обновляем пути в таблице chapters
|
||||
chapters = db.get_all_chapters(body.url)
|
||||
for ch in chapters:
|
||||
updates = {}
|
||||
for col in ("output_cbz", "output_pdf", "output_epub"):
|
||||
p = ch.get(col)
|
||||
if p and str(old_dir) in p:
|
||||
updates[col] = p.replace(str(old_dir), str(new_dir))
|
||||
if updates:
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
db.conn.execute(
|
||||
f"UPDATE chapters SET {sets} WHERE chapter_url=?",
|
||||
[*updates.values(), ch["chapter_url"]]
|
||||
)
|
||||
db.conn.commit()
|
||||
|
||||
db.set_folder_name(body.url, new_folder)
|
||||
await ws_manager.broadcast({
|
||||
"type": "manga_folder_renamed",
|
||||
"url": body.url,
|
||||
"folder_name": new_folder,
|
||||
})
|
||||
return {"ok": True, "folder_name": new_folder}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/api/mangas/force_redownload")
|
||||
async def force_redownload(url: str):
|
||||
"""Сбросить все главы на pending и поставить мангу заново в очередь."""
|
||||
@@ -641,6 +917,7 @@ async def force_redownload(url: str):
|
||||
db.update_manga_status(url, "queued")
|
||||
await download_queue.put({"url": url, "fmt": manga["format"], "resume": False})
|
||||
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]})
|
||||
await _broadcast_queue_positions()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -664,6 +941,7 @@ async def stop_manga(url: str):
|
||||
# Манга в очереди (ещё не начата) — просто помечаем как stopped
|
||||
db.update_manga_status(url, "stopped")
|
||||
await ws_manager.broadcast({"type": "manga_stopped", "url": url})
|
||||
await _broadcast_queue_positions()
|
||||
|
||||
return {"ok": True}
|
||||
finally:
|
||||
@@ -684,6 +962,7 @@ async def resume_manga(url: str):
|
||||
db.update_manga_status(url, "queued")
|
||||
await download_queue.put({"url": url, "fmt": manga["format"]})
|
||||
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]})
|
||||
await _broadcast_queue_positions()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -701,9 +980,7 @@ async def delete_manga(url: str, delete_files: bool = False):
|
||||
|
||||
deleted_size = 0
|
||||
if delete_files:
|
||||
title = manga.get("title") or ""
|
||||
safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80]
|
||||
manga_dir = OUTPUT_DIR / safe_title
|
||||
manga_dir = _manga_folder(manga)
|
||||
if manga_dir.exists() and manga_dir.is_dir():
|
||||
deleted_size = _dir_size(manga_dir)
|
||||
import shutil
|
||||
@@ -744,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:
|
||||
# Отправляем начальный снимок состояния
|
||||
|
||||
27
src/state.py
27
src/state.py
@@ -79,6 +79,7 @@ class StateDB:
|
||||
("mangas", "last_checked_at", "TEXT"),
|
||||
("mangas", "started_at", "TEXT"),
|
||||
("mangas", "finished_at", "TEXT"),
|
||||
("mangas", "folder_name", "TEXT"),
|
||||
]
|
||||
for table, col, typedef in migrations:
|
||||
try:
|
||||
@@ -110,6 +111,32 @@ class StateDB:
|
||||
""", (title, title_ru, title_full, pub_status, chapters_total, _now(), url))
|
||||
self.conn.commit()
|
||||
|
||||
def set_folder_name(self, url: str, folder_name: str):
|
||||
"""Установить кастомное имя папки для манги."""
|
||||
self.conn.execute("""
|
||||
UPDATE mangas SET folder_name=?, updated_at=? WHERE url=?
|
||||
""", (folder_name, _now(), url))
|
||||
self.conn.commit()
|
||||
|
||||
def update_manga_meta_fields(self, url: str, title_ru: str = None,
|
||||
title_full: str = None):
|
||||
"""Обновить пользовательские метаданные манги (название серии)."""
|
||||
fields = []
|
||||
params = []
|
||||
if title_ru is not None:
|
||||
fields.extend(["title_ru=?", "title=?"])
|
||||
params.extend([title_ru, title_ru])
|
||||
if title_full is not None:
|
||||
fields.append("title_full=?")
|
||||
params.append(title_full)
|
||||
if not fields:
|
||||
return
|
||||
fields.append("updated_at=?")
|
||||
params.append(_now())
|
||||
params.append(url)
|
||||
self.conn.execute(f"UPDATE mangas SET {', '.join(fields)} WHERE url=?", params)
|
||||
self.conn.commit()
|
||||
|
||||
def set_auto_update(self, url: str, enabled: bool):
|
||||
self.conn.execute("""
|
||||
UPDATE mangas SET auto_update=?, updated_at=? WHERE url=?
|
||||
|
||||
@@ -92,7 +92,12 @@ async def download_manga(
|
||||
"chapters_total": len(manga.chapters),
|
||||
})
|
||||
|
||||
folder_name = _safe_name(manga.title_ru or manga.title)
|
||||
# Используем кастомное имя папки из БД, если задано
|
||||
_db_manga = await db_call(db.get_manga, url)
|
||||
folder_name = (
|
||||
(_db_manga.get("folder_name") if _db_manga else None)
|
||||
or _safe_name(manga.title_ru or manga.title)
|
||||
)
|
||||
manga_dir = output_dir / folder_name
|
||||
manga_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user