Compare commits

..

3 Commits

Author SHA1 Message Date
88bf301b60 upd 2026-04-30 17:45:16 +03:00
77592c9a55 upd 2026-04-30 17:18:03 +03:00
7c5ce807b8 upd 2026-04-30 17:14:21 +03:00
8 changed files with 820 additions and 118 deletions

View File

@@ -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` восстанавливает незавершённые задачи из БД в очередь.

View File

@@ -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. Нажмите **Переименовать**.
После переименования:
- Физическая папка на диске будет переименована.
- Пути ко всем уже скачанным файлам обновятся в БД.
- Дозагрузка новых глав продолжится в переименованную папку.

View File

@@ -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"

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── 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>

View File

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

View File

@@ -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:
# Отправляем начальный снимок состояния

View File

@@ -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=?

View File

@@ -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)