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 | | Изображения | `Pillow==10.3.0` | Открытие/конвертация для PDF |
| PDF | `img2pdf==0.5.1` (fallback: Pillow) | Склейка изображений в PDF | | PDF | `img2pdf==0.5.1` (fallback: Pillow) | Склейка изображений в PDF |
| EPUB | `ebooklib==0.18` | Сборка EPUB3-файла | | EPUB | `ebooklib==0.18` | Сборка EPUB3-файла |
| Расписание | `croniter==3.0.3` | Парсинг cron-выражений для планировщика |
| Логирование | `loguru==0.7.2` | Удобные форматированные логи | | Логирование | `loguru==0.7.2` | Удобные форматированные логи |
| Фронтенд | Tailwind CSS (CDN) + Vanilla JS | SPA без сборщика | | Фронтенд | Tailwind CSS (CDN) + Vanilla JS | SPA без сборщика |
@@ -249,6 +250,7 @@ class MangaInfo:
| `chapters_total` | INTEGER | Кол-во глав (из scraper) | | `chapters_total` | INTEGER | Кол-во глав (из scraper) |
| `chapters_done` | INTEGER | **Стальной счётчик** — не используется в API напрямую¹ | | `chapters_done` | INTEGER | **Стальной счётчик** — не используется в API напрямую¹ |
| `last_checked_at` | TEXT | Время последней проверки новых глав | | `last_checked_at` | TEXT | Время последней проверки новых глав |
| `folder_name` | TEXT | Кастомное имя папки на диске (NULL → вычисляется из `title_ru`) |
¹ API всегда пересчитывает `chapters_done` запросом `SELECT COUNT(*) FROM chapters WHERE status='done'`. ¹ API всегда пересчитывает `chapters_done` запросом `SELECT COUNT(*) FROM chapters WHERE status='done'`.
@@ -280,6 +282,8 @@ class MangaInfo:
- `chapter_status(chapter_url)``str | None`. - `chapter_status(chapter_url)``str | None`.
- `sync_chapters_done(url)` — пересчитывает и сохраняет `chapters_done` из таблицы chapters, возвращает число. - `sync_chapters_done(url)` — пересчитывает и сохраняет `chapters_done` из таблицы chapters, возвращает число.
- `get_autos()` — манги с `auto_update=1` не в статусе `downloading`. - `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 3. get_manga_info() → MangaInfo
4. update_manga_info() в БД 4. update_manga_info() в БД
5. upsert_chapter() для каждой главы 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_skip (status == "done" и resume=True)
└── to_download (всё остальное) └── to_download (всё остальное)
7. Отправляем chapter_skipped события для to_skip 8. Отправляем chapter_skipped события для to_skip
8. asyncio.Semaphore(chapter_concurrency) 9. asyncio.Semaphore(chapter_concurrency)
9. asyncio.gather(*[process_chapter(ch) for ch in to_download]) 10. asyncio.gather(*[process_chapter(ch) for ch in to_download])
10. sync_chapters_done() → update_manga_status → "done" 11. sync_chapters_done() → update_manga_status → "done"
``` ```
#### `process_chapter(ch)` (внутренняя корутина) #### `process_chapter(ch)` (внутренняя корутина)
@@ -344,6 +351,12 @@ active_tasks: dict[str, asyncio.Task] # url → текущая Task загру
ws_manager: ConnectionManager # set активных WebSocket-соединений 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`) #### Жизненный цикл при старте (`startup_event`)
1. Запускает `queue_worker()` как фоновую Task. 1. Запускает `queue_worker()` как фоновую Task.
@@ -356,9 +369,11 @@ ws_manager: ConnectionManager # set активных WebSocket-соедине
#### `update_scheduler()` #### `update_scheduler()`
Через 5 минут после старта, затем каждые `UPDATE_INTERVAL_HOURS` (по умолчанию 6 ч): Через 5 минут после старта, затем по расписанию `UPDATE_SCHEDULE` (cron-синтаксис):
- Вызывает `check_for_updates()` для каждой манги с `auto_update=1`. - Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается.
- При нахождении новых глав — добавляет задачу в очередь с флагом `is_update=True`. - Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него.
- Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**.
- `_run_auto_updates()` — сама логика: вызывает `check_for_updates()` для каждой манги с `auto_update=1`, ставит новые главы в очередь.
#### `_enrich_manga(m, db)` #### `_enrich_manga(m, db)`
@@ -425,6 +440,10 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
| `POST` | `/api/mangas/retry_errors?url=` | Сбросить failed/partial главы → pending | | `POST` | `/api/mangas/retry_errors?url=` | Сбросить failed/partial главы → pending |
| `POST` | `/api/mangas/auto_update?url=&enabled=` | Вкл/выкл авто-обновление | | `POST` | `/api/mangas/auto_update?url=&enabled=` | Вкл/выкл авто-обновление |
| `POST` | `/api/mangas/check_now?url=` | Немедленно проверить новые главы | | `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=` | Удалить мангу из БД | | `DELETE` | `/api/mangas?url=` | Удалить мангу из БД |
| `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) | | `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) |
| `GET` | `/api/history?limit=&manga_url=` | История событий | | `GET` | `/api/history?limit=&manga_url=` | История событий |
@@ -448,6 +467,9 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
| `manga_stopped` | `{url}` | Остановлена | | `manga_stopped` | `{url}` | Остановлена |
| `manga_prioritized` | `{url, preempted_url}` | Приоритет изменён | | `manga_prioritized` | `{url, preempted_url}` | Приоритет изменён |
| `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления | | `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_start` | `{url, chapter_url, chapter_number, chapters_done, chapters_total}` | Начало главы |
| `chapter_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова | | `chapter_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова |
| `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) | | `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) |
@@ -456,6 +478,7 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
| `check_started` / `check_done` | `{url, new_chapters?}` | Проверка обновлений | | `check_started` / `check_done` | `{url, new_chapters?}` | Проверка обновлений |
| `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава | | `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава |
| `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления | | `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления |
| `meta_refreshed` | `{url, updated, failed}` | Метаданные файлов обновлены |
### Клиент → Сервер ### Клиент → Сервер
@@ -467,7 +490,7 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
## 9. Фронтенд ## 9. Фронтенд
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~1000 строк). **Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~1500 строк).
**Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика). **Стек:** 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) Отображаются на карточке как «Позиция в очереди: N». Обновляются в реальном времени через событие `queue_positions` (не перерендер всего списка, а точечное обновление через `updateMangaRow`). Событие рассылается сервером при каждом изменении состояния очереди.
└── 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.
--- ---
@@ -590,7 +559,9 @@ asyncio.Semaphore(CHAPTER_CONCURRENCY)
| Переменная | Default | Описание | | Переменная | Default | Описание |
|------------|---------|---------| |------------|---------|---------|
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно | | `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) | | `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
### Пути (hardcoded в коде) ### Пути (hardcoded в коде)
@@ -630,7 +601,9 @@ ports:
shm_size: "2gb" # Chromium требует shared memory shm_size: "2gb" # Chromium требует shared memory
environment: environment:
- UPDATE_INTERVAL_HOURS=6 - UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron)
- AUTH_LOGIN=...
- AUTH_PASSWORD=...
restart: unless-stopped # Автоперезапуск при падении restart: unless-stopped # Автоперезапуск при падении
``` ```
@@ -655,4 +628,3 @@ docker compose run --rm --entrypoint "" manga \
- `./state/manga.log` — логи. - `./state/manga.log` — логи.
При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь. При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь.

View File

@@ -103,12 +103,37 @@ output/
--- ---
## Авторизация
Задайте в `docker-compose.yml`:
```yaml
- AUTH_LOGIN=ваш_логин
- AUTH_PASSWORD=ваш_пароль
```
Если оба параметра заданы — интерфейс будет защищён формой входа. Сессия сохраняется в браузере на 30 дней, повторный вход не требуется.
---
## Конфигурация (docker-compose.yml) ## Конфигурация (docker-compose.yml)
| Переменная | Default | Описание | | Переменная | Default | Описание |
|------------|---------|---------| |------------|---------|---------|
| `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно | | `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно |
| `UPDATE_INTERVAL_HOURS` | `6` | Интервал авто-проверки новых глав (часы) | | `UPDATE_SCHEDULE` | — | Расписание авто-проверки новых глав (cron-синтаксис). Если пусто — отключено |
| `UPDATE_INTERVAL_HOURS` | — | Устаревший формат: число часов (конвертируется в cron автоматически) |
| `AUTH_LOGIN` / `AUTH_PASSWORD` | — | Логин и пароль для веб-интерфейса |
### Примеры расписания (`UPDATE_SCHEDULE`)
```
0 */6 * * * — каждые 6 часов
0 3 * * * — каждый день в 03:00 UTC
0 3 * * MON — каждый понедельник в 03:00
*/30 * * * * — каждые 30 минут
— (пусто) — планировщик отключён
```
--- ---
@@ -130,4 +155,33 @@ output/
Для завершённых серий (`pub_status = completed`) в `ComicInfo.xml` записывается поле `<Count>` — Komga отображает прогресс чтения серии. Для завершённых серий (`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 - ./state:/app/state
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- UPDATE_INTERVAL_HOURS=6 # Расписание авто-проверки новых глав (cron-синтаксис).
# Примеры: "0 */6 * * *" — каждые 6 ч | "0 3 * * *" — каждый день в 03:00
# Оставьте пустым чтобы отключить планировщик.
# Устаревший формат UPDATE_INTERVAL_HOURS=6 тоже поддерживается.
- UPDATE_SCHEDULE=0 */6 * * *
# Авторизация (оба параметра должны быть заданы чтобы включить защиту)
- AUTH_LOGIN=StenFredd
- AUTH_PASSWORD=111111
ports: ports:
- "8000:8000" - "8000:8000"
shm_size: "2gb" shm_size: "2gb"

View File

@@ -50,10 +50,40 @@
.pulse-dot { width:8px; height:8px; border-radius:50%; background:#3b82f6; animation:pulse 1.5s infinite; } .pulse-dot { width:8px; height:8px; border-radius:50%; background:#3b82f6; animation:pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} } @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:#0f1117; } ::-webkit-scrollbar-thumb { background:#2d3148; border-radius:3px; } ::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:#0f1117; } ::-webkit-scrollbar-thumb { background:#2d3148; border-radius:3px; }
/* Login screen */
#login-screen { position:fixed; inset:0; z-index:9999; background:#0f1117; display:flex; align-items:center; justify-content:center; }
#login-screen.hidden { display:none; }
.login-card { background:#1a1d2e; border:1px solid #2d3148; border-radius:16px; padding:40px; width:100%; max-width:380px; }
</style> </style>
</head> </head>
<body class="min-h-screen"> <body class="min-h-screen">
<!-- Login screen -->
<div id="login-screen">
<div class="login-card">
<div class="flex items-center gap-3 mb-8 justify-center">
<span class="text-3xl">📚</span>
<h1 class="text-xl font-bold text-white">Manga Downloader</h1>
</div>
<div class="flex flex-col gap-4">
<div>
<label class="text-xs text-gray-400 mb-1 block">Логин</label>
<input id="login-input" type="text" autocomplete="username"
class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
style="background:#0f1117" placeholder="Логин">
</div>
<div>
<label class="text-xs text-gray-400 mb-1 block">Пароль</label>
<input id="password-input" type="password" autocomplete="current-password"
class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
style="background:#0f1117" placeholder="Пароль">
</div>
<div id="login-error" class="text-sm text-red-400 hidden"></div>
<button id="login-btn" onclick="doLogin()" class="btn-primary w-full mt-2">Войти</button>
</div>
</div>
</div>
<!-- Header --> <!-- Header -->
<header class="border-b border-gray-800 px-6 py-4 flex items-center justify-between sticky top-0 z-50" style="background:#0f1117ee;backdrop-filter:blur(12px)"> <header class="border-b border-gray-800 px-6 py-4 flex items-center justify-between sticky top-0 z-50" style="background:#0f1117ee;backdrop-filter:blur(12px)">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -65,6 +95,7 @@
<div class="w-2 h-2 rounded-full bg-gray-600" id="ws-dot"></div> <div class="w-2 h-2 rounded-full bg-gray-600" id="ws-dot"></div>
<span id="ws-text">Подключение...</span> <span id="ws-text">Подключение...</span>
</div> </div>
<button id="logout-btn" onclick="doLogout()" class="hidden text-xs text-gray-500 hover:text-gray-300 px-3 py-1 rounded-lg transition-colors" style="background:#1e293b">Выйти</button>
</div> </div>
</header> </header>
@@ -192,6 +223,58 @@
</div> </div>
</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> <script>
// ── State ──────────────────────────────────── // ── State ────────────────────────────────────
const state = { const state = {
@@ -200,6 +283,87 @@ const state = {
filter: 'all', filter: 'all',
}; };
// ── Auth ─────────────────────────────────────
function showLoginScreen() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('logout-btn').classList.add('hidden');
// Закрываем WS если открыт
if(ws) { try { ws.close(); } catch(_){} ws = null; }
}
function hideLoginScreen() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('logout-btn').classList.remove('hidden');
}
async function checkAuth() {
try {
const r = await fetch('/api/auth/check');
const data = await r.json();
if(!data.auth_enabled) { hideLoginScreen(); return true; }
if(data.authenticated) { hideLoginScreen(); return true; }
showLoginScreen();
return false;
} catch(e) {
showLoginScreen();
return false;
}
}
async function doLogin() {
const btn = document.getElementById('login-btn');
const err = document.getElementById('login-error');
const login = document.getElementById('login-input').value.trim();
const password = document.getElementById('password-input').value;
err.classList.add('hidden');
btn.disabled = true; btn.textContent = 'Входим...';
try {
const r = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({login, password}),
});
if(!r.ok) {
const d = await r.json().catch(()=>({}));
err.textContent = d.detail || 'Неверный логин или пароль';
err.classList.remove('hidden');
return;
}
hideLoginScreen();
await initApp();
} catch(e) {
err.textContent = 'Ошибка сети';
err.classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = 'Войти';
}
}
async function doLogout() {
await fetch('/api/logout', {method:'POST'}).catch(()=>{});
showLoginScreen();
document.getElementById('login-input').value = '';
document.getElementById('password-input').value = '';
// Сбрасываем состояние
Object.keys(state.mangas).forEach(k => delete state.mangas[k]);
Object.keys(state.chapters).forEach(k => delete state.chapters[k]);
document.getElementById('stats-row').innerHTML = '';
document.getElementById('manga-list').innerHTML = '';
}
// Глобальный перехват 401
const _origFetch = window.fetch;
window.fetch = async function(...args) {
const r = await _origFetch.apply(this, args);
if(r.status === 401) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
if(!url.includes('/api/auth/check') && !url.includes('/api/login')) {
showLoginScreen();
}
}
return r;
};
// ── WebSocket ──────────────────────────────── // ── WebSocket ────────────────────────────────
let ws, wsReconnectTimer; let ws, wsReconnectTimer;
@@ -212,11 +376,17 @@ function connectWS() {
document.getElementById('ws-text').textContent = 'Подключено'; document.getElementById('ws-text').textContent = 'Подключено';
clearTimeout(wsReconnectTimer); clearTimeout(wsReconnectTimer);
// Keepalive // Keepalive
setInterval(() => { if(ws.readyState===1) ws.send('ping'); }, 20000); setInterval(() => { if(ws && ws.readyState===1) ws.send('ping'); }, 20000);
}; };
ws.onclose = () => { ws.onclose = (e) => {
document.getElementById('ws-dot').className = 'w-2 h-2 rounded-full bg-red-500'; document.getElementById('ws-dot').className = 'w-2 h-2 rounded-full bg-red-500';
if(e.code === 4401) {
// Сессия истекла или не авторизован
document.getElementById('ws-text').textContent = 'Нет доступа';
showLoginScreen();
return;
}
document.getElementById('ws-text').textContent = 'Переподключение...'; document.getElementById('ws-text').textContent = 'Переподключение...';
wsReconnectTimer = setTimeout(connectWS, 3000); wsReconnectTimer = setTimeout(connectWS, 3000);
}; };
@@ -377,6 +547,34 @@ function handleEvent(msg) {
// Ничего не делаем визуально — файлы обновлены на диске // Ничего не делаем визуально — файлы обновлены на диске
break; 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': case 'manga_done':
if(state.mangas[msg.url]) { if(state.mangas[msg.url]) {
state.mangas[msg.url].status = 'done'; 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'}); await fetch('/api/mangas/check_now?url='+encodeURIComponent(url), {method:'POST'});
} }
async function checkNowBtn(btn, url) {
if(btn.disabled) return;
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = '⏳';
btn.style.color = '#fbbf24';
try {
const r = await fetch('/api/mangas/check_now?url='+encodeURIComponent(url), {method:'POST'});
if(r.ok) {
btn.textContent = '✓';
btn.style.color = '#4ade80';
setTimeout(() => {
btn.textContent = orig;
btn.style.color = '';
btn.disabled = false;
}, 2500);
} else {
throw new Error();
}
} catch {
btn.textContent = '✕';
btn.style.color = '#f87171';
setTimeout(() => {
btn.textContent = orig;
btn.style.color = '';
btn.disabled = false;
}, 2000);
}
}
// ── API ────────────────────────────────────── // ── API ──────────────────────────────────────
async function loadStats() { async function loadStats() {
try { try {
@@ -948,7 +1176,7 @@ function _rowButtons(m) {
${m.status === 'stopped' || m.status === 'failed' ${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>` ? `<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="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> <button onclick="deleteManga('${u}')" class="btn-danger" title="Удалить">✕</button>
@@ -966,7 +1194,10 @@ function _rowAuto(m) {
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<span>Авто</span> <span>Авто</span>
${autoOn ? `<button onclick="checkNow('${u}')" class="text-indigo-400 hover:text-indigo-300 text-xs">↻</button>` : ''} <button onclick="event.stopPropagation(); checkNowBtn(this, '${u}')"
title="Проверить новые главы сейчас"
class="text-indigo-400 hover:text-white transition-colors px-1 rounded"
style="line-height:1">↻</button>
</div>`; </div>`;
} }
@@ -1185,6 +1416,17 @@ function renderModalBody(data) {
</details>` : '<div class="text-xs text-gray-500 mb-3">Файлов на диске нет</div>'} </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"> <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' ? ` ${data.status === 'done' ? `
<button id="modal-refresh-meta-btn" onclick="refreshMetaModal('${escHtml(data.url)}')" <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" 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;'); 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 ───────────────────────────────────── // ── Init ─────────────────────────────────────
async function init() { async function initApp() {
_initDeleteModal(); _initDeleteModal();
await loadStats(); await loadStats();
connectWS(); connectWS();
// Загружаем список манги // Загружаем список манги
try { try {
const r = await fetch('/api/mangas'); const r = await fetch('/api/mangas');
if(r.ok) {
const mangas = await r.json(); const mangas = await r.json();
mangas.forEach(m => { state.mangas[m.url] = m; }); mangas.forEach(m => { state.mangas[m.url] = m; });
renderList(); renderList();
}
} catch(e) {} } catch(e) {}
setInterval(loadStats, 15000); setInterval(loadStats, 15000);
} }
document.addEventListener('DOMContentLoaded', init); async function init() {
const ok = await checkAuth();
if(ok) await initApp();
}
document.addEventListener('DOMContentLoaded', () => {
init();
// Enter в полях логина
['login-input','password-input'].forEach(id => {
document.getElementById(id).addEventListener('keydown', e => {
if(e.key === 'Enter') doLogin();
});
});
});
// Закрытие модалки по клику снаружи // Закрытие модалки по клику снаружи
document.getElementById('modal').addEventListener('click', function(e) { document.getElementById('modal').addEventListener('click', function(e) {
if(e.target === this) closeModal(); 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> </script>
</body> </body>
</html> </html>

View File

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

View File

@@ -2,14 +2,18 @@
FastAPI веб-сервер: REST API + WebSocket для мониторинга загрузок манги. FastAPI веб-сервер: REST API + WebSocket для мониторинга загрузок манги.
""" """
import asyncio import asyncio
import hashlib
import hmac as _hmac
import os import os
import re import re
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException from croniter import croniter
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from loguru import logger from loguru import logger
@@ -20,8 +24,45 @@ from .exporter import patch_meta, MangaMeta
OUTPUT_DIR = Path("/app/output") OUTPUT_DIR = Path("/app/output")
FRONTEND_DIR = Path("/app/frontend") FRONTEND_DIR = Path("/app/frontend")
# ── Авторизация ───────────────────────────────
AUTH_LOGIN = os.getenv("AUTH_LOGIN", "")
AUTH_PASSWORD = os.getenv("AUTH_PASSWORD", "")
AUTH_ENABLED = bool(AUTH_LOGIN and AUTH_PASSWORD)
COOKIE_NAME = "manga_session"
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 дней
def _compute_token() -> str:
"""Стабильный токен сессии, производный от credentials."""
return _hmac.new(
AUTH_PASSWORD.encode(),
AUTH_LOGIN.encode(),
hashlib.sha256,
).hexdigest()
_VALID_TOKEN: str = _compute_token() if AUTH_ENABLED else ""
# Пути, доступные без авторизации
_AUTH_EXEMPT = {"/api/login", "/api/auth/check", "/api/logout"}
app = FastAPI(title="Manga Downloader API") app = FastAPI(title="Manga Downloader API")
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
"""Проверяет авторизацию для всех /api/* эндпоинтов."""
if not AUTH_ENABLED:
return await call_next(request)
path = request.url.path
# Пропускаем статику и исключения
if not path.startswith("/api") or path in _AUTH_EXEMPT:
return await call_next(request)
token = request.cookies.get(COOKIE_NAME)
if token != _VALID_TOKEN:
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
return await call_next(request)
# ── WebSocket менеджер ──────────────────────── # ── WebSocket менеджер ────────────────────────
class ConnectionManager: class ConnectionManager:
@@ -55,6 +96,13 @@ download_queue: asyncio.Queue = asyncio.Queue()
active_tasks: dict[str, asyncio.Task] = {} 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(): async def queue_worker():
"""Последовательно обрабатывает очередь загрузок. Перезапускается при краше.""" """Последовательно обрабатывает очередь загрузок. Перезапускается при краше."""
while True: while True:
@@ -84,9 +132,12 @@ async def _queue_worker_loop():
if skip: if skip:
download_queue.task_done() download_queue.task_done()
await _broadcast_queue_positions()
continue continue
logger.info("Воркер: начинаю скачивать {}", url) logger.info("Воркер: начинаю скачивать {}", url)
# Позиции изменились — уведомляем клиентов
await _broadcast_queue_positions()
dl_task = asyncio.create_task(download_manga( dl_task = asyncio.create_task(download_manga(
url=url, url=url,
fmt=fmt, fmt=fmt,
@@ -116,6 +167,7 @@ async def _queue_worker_loop():
finally: finally:
active_tasks.pop(url, None) active_tasks.pop(url, None)
download_queue.task_done() download_queue.task_done()
await _broadcast_queue_positions()
@app.on_event("startup") @app.on_event("startup")
@@ -134,16 +186,99 @@ async def startup_event():
db.close() db.close()
def _parse_schedule() -> Optional[str]:
"""
Читает расписание из переменных окружения.
Приоритет: UPDATE_SCHEDULE (cron-строка) → UPDATE_INTERVAL_HOURS (число часов, legacy).
Возвращает cron-строку или None если планировщик отключён.
"""
schedule = os.getenv("UPDATE_SCHEDULE", "").strip()
if schedule:
# Валидируем cron-выражение
if croniter.is_valid(schedule):
return schedule
logger.error("UPDATE_SCHEDULE='{}' — невалидное cron-выражение, планировщик отключён", schedule)
return None
# Обратная совместимость: UPDATE_INTERVAL_HOURS → конвертируем в cron
hours_raw = os.getenv("UPDATE_INTERVAL_HOURS", "").strip()
if not hours_raw:
return None
try:
hours = float(hours_raw)
if hours <= 0:
return None
# Конвертируем в cron: каждые N часов (если целое и делит 24) или фиксированное время
h = int(hours)
if h == hours and 24 % h == 0:
cron = f"0 */{h} * * *"
else:
# Нецелое или не делит 24 — берём ближайшее целое число часов
h = max(1, round(hours))
cron = f"0 */{h} * * *" if 24 % h == 0 else f"0 0/{h} * * *"
logger.info("UPDATE_INTERVAL_HOURS={} → cron: '{}'", hours_raw, cron)
return cron
except ValueError:
logger.error("UPDATE_INTERVAL_HOURS='{}' — не число, планировщик отключён", hours_raw)
return None
async def update_scheduler(): async def update_scheduler():
"""Периодически проверяет новые главы для манг с auto_update=1.""" """
interval_hours = float(os.getenv("UPDATE_INTERVAL_HOURS", "6")) Планировщик авто-обновлений на основе cron-расписания.
interval_sec = interval_hours * 3600 При любой ошибке — 3 попытки с интервалом 5 мин, затем ждёт следующего слота.
logger.info("Планировщик обновлений: каждые {} ч", interval_hours) Цикл никогда не прерывается.
# Первый запуск — через 5 минут после старта """
cron_expr = _parse_schedule()
if not cron_expr:
logger.info("Планировщик обновлений отключён (UPDATE_SCHEDULE и UPDATE_INTERVAL_HOURS не заданы)")
return
logger.info("Планировщик обновлений запущен: '{}'", cron_expr)
# Первый запуск — через 5 минут после старта (не сразу, чтобы не мешать инициализации)
await asyncio.sleep(300) await asyncio.sleep(300)
while True: while True:
# Вычисляем время до следующего запуска
now_utc = datetime.now(timezone.utc)
now_naive = now_utc.replace(tzinfo=None) # croniter работает с naive datetime
cron = croniter(cron_expr, now_naive)
next_run: datetime = cron.get_next(datetime)
wait_sec = max(0.0, (next_run - now_naive).total_seconds())
logger.info("Следующая проверка обновлений: {} UTC (через {:.0f} мин)",
next_run.strftime("%Y-%m-%d %H:%M"), wait_sec / 60)
await asyncio.sleep(wait_sec)
# Запускаем с retry-логикой
await _run_auto_updates_with_retry()
async def _run_auto_updates_with_retry():
"""Запускает _run_auto_updates с тремя попытками при ошибке."""
max_attempts = 3
retry_delay = 300 # 5 минут между попытками
for attempt in range(1, max_attempts + 1):
try:
await _run_auto_updates() await _run_auto_updates()
await asyncio.sleep(interval_sec) return # успех
except asyncio.CancelledError:
raise # не перехватываем отмену
except Exception as e:
if attempt < max_attempts:
logger.warning(
"Авто-обновление: попытка {}/{} завершилась ошибкой: {}. "
"Повтор через {} сек.", attempt, max_attempts, e, retry_delay
)
await asyncio.sleep(retry_delay)
else:
logger.error(
"Авто-обновление: все {} попытки исчерпаны. "
"Последняя ошибка: {}. Ждём следующего слота по расписанию.",
max_attempts, e
)
async def _run_auto_updates(): async def _run_auto_updates():
@@ -165,7 +300,6 @@ async def _run_auto_updates():
new_chapters = await check_for_updates(url, on_event=ws_manager.broadcast) new_chapters = await check_for_updates(url, on_event=ws_manager.broadcast)
if new_chapters: if new_chapters:
logger.info("Новых глав для {}: {}", url, len(new_chapters)) logger.info("Новых глав для {}: {}", url, len(new_chapters))
# Добавляем в очередь с флагом is_update
db2 = StateDB() db2 = StateDB()
try: try:
status = db2.get_manga(url) status = db2.get_manga(url)
@@ -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: def _dir_size(path: Path) -> int:
"""Размер директории в байтах.""" """Размер директории в байтах."""
if not path.exists(): if not path.exists():
@@ -203,9 +350,7 @@ def _format_size(bytes_val: int) -> str:
def _enrich_manga(m: dict, db: StateDB) -> dict: def _enrich_manga(m: dict, db: StateDB) -> dict:
"""Обогащает строку манги реальными счётчиками из таблицы chapters.""" """Обогащает строку манги реальными счётчиками из таблицы chapters."""
title = m.get("title") or "" size_bytes = _dir_size(_manga_folder(m))
safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80]
size_bytes = _dir_size(OUTPUT_DIR / safe_title)
ch_done_count = db.conn.execute( ch_done_count = db.conn.execute(
"SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'", "SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'",
(m["url"],) (m["url"],)
@@ -238,9 +383,7 @@ def _manga_detail(manga: dict, db: StateDB) -> dict:
chapters = db.get_all_chapters(url) chapters = db.get_all_chapters(url)
# Определяем директорию манги # Определяем директорию манги
title = manga.get("title") or "" manga_dir = _manga_folder(manga)
safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80]
manga_dir = OUTPUT_DIR / safe_title
size_bytes = _dir_size(manga_dir) size_bytes = _dir_size(manga_dir)
# Файлы # Файлы
@@ -313,6 +456,47 @@ class AddMangaRequest(BaseModel):
format: str = "cbz" format: str = "cbz"
# ── Auth API ─────────────────────────────────
class LoginRequest(BaseModel):
login: str
password: str
@app.get("/api/auth/check")
async def auth_check(request: Request):
"""Проверить, авторизован ли пользователь."""
if not AUTH_ENABLED:
return {"authenticated": True, "auth_enabled": False}
ok = request.cookies.get(COOKIE_NAME) == _VALID_TOKEN
return {"authenticated": ok, "auth_enabled": True}
@app.post("/api/login")
async def login(body: LoginRequest, response: Response):
if not AUTH_ENABLED:
return {"ok": True}
if body.login != AUTH_LOGIN or body.password != AUTH_PASSWORD:
raise HTTPException(status_code=401, detail="Неверный логин или пароль")
response.set_cookie(
key=COOKIE_NAME,
value=_VALID_TOKEN,
max_age=COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=False, # включите True если HTTPS
)
return {"ok": True}
@app.post("/api/logout")
async def logout(response: Response):
response.delete_cookie(COOKIE_NAME)
return {"ok": True}
# ── REST API ──────────────────────────────────
@app.get("/api/mangas") @app.get("/api/mangas")
async def list_mangas(): async def list_mangas():
db = StateDB() db = StateDB()
@@ -361,6 +545,7 @@ async def add_to_queue(body: AddMangaRequest):
"url": url, "url": url,
"format": body.format, "format": body.format,
}) })
await _broadcast_queue_positions()
# Запускаем фоновую задачу предпросмотра (без Chromium — быстро) # Запускаем фоновую задачу предпросмотра (без Chromium — быстро)
asyncio.create_task(_fetch_preview(url)) asyncio.create_task(_fetch_preview(url))
else: else:
@@ -525,6 +710,7 @@ async def prioritize_manga(url: str):
"url": url, "url": url,
"preempted_url": current_url, "preempted_url": current_url,
}) })
await _broadcast_queue_positions()
return {"ok": True} return {"ok": True}
finally: finally:
db.close() db.close()
@@ -619,6 +805,96 @@ async def _do_refresh_meta(url: str):
db.close() 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") @app.post("/api/mangas/force_redownload")
async def force_redownload(url: str): async def force_redownload(url: str):
"""Сбросить все главы на pending и поставить мангу заново в очередь.""" """Сбросить все главы на pending и поставить мангу заново в очередь."""
@@ -641,6 +917,7 @@ async def force_redownload(url: str):
db.update_manga_status(url, "queued") db.update_manga_status(url, "queued")
await download_queue.put({"url": url, "fmt": manga["format"], "resume": False}) await download_queue.put({"url": url, "fmt": manga["format"], "resume": False})
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]}) await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]})
await _broadcast_queue_positions()
return {"ok": True} return {"ok": True}
finally: finally:
db.close() db.close()
@@ -664,6 +941,7 @@ async def stop_manga(url: str):
# Манга в очереди (ещё не начата) — просто помечаем как stopped # Манга в очереди (ещё не начата) — просто помечаем как stopped
db.update_manga_status(url, "stopped") db.update_manga_status(url, "stopped")
await ws_manager.broadcast({"type": "manga_stopped", "url": url}) await ws_manager.broadcast({"type": "manga_stopped", "url": url})
await _broadcast_queue_positions()
return {"ok": True} return {"ok": True}
finally: finally:
@@ -684,6 +962,7 @@ async def resume_manga(url: str):
db.update_manga_status(url, "queued") db.update_manga_status(url, "queued")
await download_queue.put({"url": url, "fmt": manga["format"]}) await download_queue.put({"url": url, "fmt": manga["format"]})
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]}) await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]})
await _broadcast_queue_positions()
return {"ok": True} return {"ok": True}
finally: finally:
db.close() db.close()
@@ -701,9 +980,7 @@ async def delete_manga(url: str, delete_files: bool = False):
deleted_size = 0 deleted_size = 0
if delete_files: if delete_files:
title = manga.get("title") or "" manga_dir = _manga_folder(manga)
safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80]
manga_dir = OUTPUT_DIR / safe_title
if manga_dir.exists() and manga_dir.is_dir(): if manga_dir.exists() and manga_dir.is_dir():
deleted_size = _dir_size(manga_dir) deleted_size = _dir_size(manga_dir)
import shutil import shutil
@@ -744,6 +1021,10 @@ async def global_stats():
@app.websocket("/ws") @app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket): async def websocket_endpoint(ws: WebSocket):
# Проверяем авторизацию по cookie
if AUTH_ENABLED and ws.cookies.get(COOKIE_NAME) != _VALID_TOKEN:
await ws.close(code=4401)
return
await ws_manager.connect(ws) await ws_manager.connect(ws)
try: try:
# Отправляем начальный снимок состояния # Отправляем начальный снимок состояния

View File

@@ -79,6 +79,7 @@ class StateDB:
("mangas", "last_checked_at", "TEXT"), ("mangas", "last_checked_at", "TEXT"),
("mangas", "started_at", "TEXT"), ("mangas", "started_at", "TEXT"),
("mangas", "finished_at", "TEXT"), ("mangas", "finished_at", "TEXT"),
("mangas", "folder_name", "TEXT"),
] ]
for table, col, typedef in migrations: for table, col, typedef in migrations:
try: try:
@@ -110,6 +111,32 @@ class StateDB:
""", (title, title_ru, title_full, pub_status, chapters_total, _now(), url)) """, (title, title_ru, title_full, pub_status, chapters_total, _now(), url))
self.conn.commit() 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): def set_auto_update(self, url: str, enabled: bool):
self.conn.execute(""" self.conn.execute("""
UPDATE mangas SET auto_update=?, updated_at=? WHERE url=? UPDATE mangas SET auto_update=?, updated_at=? WHERE url=?

View File

@@ -92,7 +92,12 @@ async def download_manga(
"chapters_total": len(manga.chapters), "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 = output_dir / folder_name
manga_dir.mkdir(parents=True, exist_ok=True) manga_dir.mkdir(parents=True, exist_ok=True)