Compare commits
3 Commits
0aa057c991
...
88bf301b60
| Author | SHA1 | Date | |
|---|---|---|---|
| 88bf301b60 | |||
| 77592c9a55 | |||
| 7c5ce807b8 |
136
ARCHITECTURE.md
136
ARCHITECTURE.md
@@ -84,6 +84,7 @@ manga/
|
|||||||
| Изображения | `Pillow==10.3.0` | Открытие/конвертация для PDF |
|
| Изображения | `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` восстанавливает незавершённые задачи из БД в очередь.
|
||||||
|
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -103,12 +103,37 @@ output/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Авторизация
|
||||||
|
|
||||||
|
Задайте в `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- AUTH_LOGIN=ваш_логин
|
||||||
|
- AUTH_PASSWORD=ваш_пароль
|
||||||
|
```
|
||||||
|
|
||||||
|
Если оба параметра заданы — интерфейс будет защищён формой входа. Сессия сохраняется в браузере на 30 дней, повторный вход не требуется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Конфигурация (docker-compose.yml)
|
## Конфигурация (docker-compose.yml)
|
||||||
|
|
||||||
| Переменная | Default | Описание |
|
| Переменная | Default | Описание |
|
||||||
|------------|---------|---------|
|
|------------|---------|---------|
|
||||||
| `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно |
|
| `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно |
|
||||||
| `UPDATE_INTERVAL_HOURS` | `6` | Интервал авто-проверки новых глав (часы) |
|
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки новых глав (cron-синтаксис). Если пусто — отключено |
|
||||||
|
| `UPDATE_INTERVAL_HOURS` | — | Устаревший формат: число часов (конвертируется в cron автоматически) |
|
||||||
|
| `AUTH_LOGIN` / `AUTH_PASSWORD` | — | Логин и пароль для веб-интерфейса |
|
||||||
|
|
||||||
|
### Примеры расписания (`UPDATE_SCHEDULE`)
|
||||||
|
|
||||||
|
```
|
||||||
|
0 */6 * * * — каждые 6 часов
|
||||||
|
0 3 * * * — каждый день в 03:00 UTC
|
||||||
|
0 3 * * MON — каждый понедельник в 03:00
|
||||||
|
*/30 * * * * — каждые 30 минут
|
||||||
|
— (пусто) — планировщик отключён
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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. Нажмите **Переименовать**.
|
||||||
|
|
||||||
|
После переименования:
|
||||||
|
- Физическая папка на диске будет переименована.
|
||||||
|
- Пути ко всем уже скачанным файлам обновятся в БД.
|
||||||
|
- Дозагрузка новых глав продолжится в переименованную папку.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ services:
|
|||||||
- ./state:/app/state
|
- ./state:/app/state
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- UPDATE_INTERVAL_HOURS=6
|
# Расписание авто-проверки новых глав (cron-синтаксис).
|
||||||
|
# Примеры: "0 */6 * * *" — каждые 6 ч | "0 3 * * *" — каждый день в 03:00
|
||||||
|
# Оставьте пустым чтобы отключить планировщик.
|
||||||
|
# Устаревший формат UPDATE_INTERVAL_HOURS=6 тоже поддерживается.
|
||||||
|
- UPDATE_SCHEDULE=0 */6 * * *
|
||||||
|
# Авторизация (оба параметра должны быть заданы чтобы включить защиту)
|
||||||
|
- AUTH_LOGIN=StenFredd
|
||||||
|
- AUTH_PASSWORD=111111
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
shm_size: "2gb"
|
shm_size: "2gb"
|
||||||
|
|||||||
@@ -50,22 +50,53 @@
|
|||||||
.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">
|
||||||
<span class="text-2xl">📚</span>
|
<span class="text-2xl">📚</span>
|
||||||
<h1 class="text-xl font-bold text-white">Manga Downloader</h1>
|
<h1 class="text-xl font-bold text-white">Manga Downloader</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div id="ws-status" class="flex items-center gap-2 text-sm text-gray-400">
|
<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>
|
<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>
|
||||||
|
<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>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
<div class="max-w-7xl mx-auto px-4 py-6">
|
||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Edit Meta ────────────────────────────────
|
||||||
|
let _editMetaUrl = null;
|
||||||
|
|
||||||
|
function openEditMeta(url) {
|
||||||
|
_editMetaUrl = url;
|
||||||
|
const m = state.mangas[url] || {};
|
||||||
|
document.getElementById('edit-meta-title-ru').value = m.title_ru || m.title || '';
|
||||||
|
document.getElementById('edit-meta-title-full').value = m.title_full || '';
|
||||||
|
const modal = document.getElementById('edit-meta-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditMeta() {
|
||||||
|
const modal = document.getElementById('edit-meta-modal');
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
_editMetaUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditMeta() {
|
||||||
|
if(!_editMetaUrl) return;
|
||||||
|
const btn = document.getElementById('edit-meta-save-btn');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ Сохраняем...';
|
||||||
|
const title_ru = document.getElementById('edit-meta-title-ru').value.trim();
|
||||||
|
const title_full = document.getElementById('edit-meta-title-full').value.trim();
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/mangas/update_meta', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({url: _editMetaUrl, title_ru, title_full}),
|
||||||
|
});
|
||||||
|
if(!r.ok) throw new Error(await r.text());
|
||||||
|
// Update local state
|
||||||
|
if(state.mangas[_editMetaUrl]) {
|
||||||
|
state.mangas[_editMetaUrl].title = title_ru;
|
||||||
|
state.mangas[_editMetaUrl].title_ru = title_ru;
|
||||||
|
state.mangas[_editMetaUrl].title_full = title_full;
|
||||||
|
}
|
||||||
|
renderList();
|
||||||
|
closeEditMeta();
|
||||||
|
} catch(e) {
|
||||||
|
alert('Ошибка: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = 'Сохранить';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rename Folder ────────────────────────────
|
||||||
|
let _renameFolderUrl = null;
|
||||||
|
|
||||||
|
function openRenameFolder(url, currentFolder) {
|
||||||
|
_renameFolderUrl = url;
|
||||||
|
const m = state.mangas[url] || {};
|
||||||
|
const cur = currentFolder || m.folder_name || (m.title_ru || m.title || '').replace(/[^\w\s\-]/g,'').trim().replace(/ /g,'_');
|
||||||
|
document.getElementById('rename-folder-input').value = cur;
|
||||||
|
const modal = document.getElementById('rename-folder-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRenameFolder() {
|
||||||
|
const modal = document.getElementById('rename-folder-modal');
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
_renameFolderUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRenameFolder() {
|
||||||
|
if(!_renameFolderUrl) return;
|
||||||
|
const btn = document.getElementById('rename-folder-save-btn');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ Переименовываем...';
|
||||||
|
const folder_name = document.getElementById('rename-folder-input').value.trim();
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/mangas/rename_folder', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({url: _renameFolderUrl, folder_name}),
|
||||||
|
});
|
||||||
|
if(!r.ok) throw new Error((await r.json()).detail || await r.text());
|
||||||
|
const data = await r.json();
|
||||||
|
if(state.mangas[_renameFolderUrl]) {
|
||||||
|
state.mangas[_renameFolderUrl].folder_name = data.folder_name;
|
||||||
|
}
|
||||||
|
closeRenameFolder();
|
||||||
|
} catch(e) {
|
||||||
|
alert('Ошибка: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = 'Переименовать';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Init ─────────────────────────────────────
|
// ── 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');
|
||||||
const mangas = await r.json();
|
if(r.ok) {
|
||||||
mangas.forEach(m => { state.mangas[m.url] = m; });
|
const mangas = await r.json();
|
||||||
renderList();
|
mangas.forEach(m => { state.mangas[m.url] = m; });
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
319
src/api.py
319
src/api.py
@@ -2,14 +2,18 @@
|
|||||||
FastAPI веб-сервер: REST API + WebSocket для мониторинга загрузок манги.
|
FastAPI веб-сервер: REST API + WebSocket для мониторинга загрузок манги.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac as _hmac
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
|
from croniter import croniter
|
||||||
|
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect, HTTPException
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -20,8 +24,45 @@ from .exporter import patch_meta, MangaMeta
|
|||||||
OUTPUT_DIR = Path("/app/output")
|
OUTPUT_DIR = Path("/app/output")
|
||||||
FRONTEND_DIR = Path("/app/frontend")
|
FRONTEND_DIR = Path("/app/frontend")
|
||||||
|
|
||||||
|
# ── Авторизация ───────────────────────────────
|
||||||
|
|
||||||
|
AUTH_LOGIN = os.getenv("AUTH_LOGIN", "")
|
||||||
|
AUTH_PASSWORD = os.getenv("AUTH_PASSWORD", "")
|
||||||
|
AUTH_ENABLED = bool(AUTH_LOGIN and AUTH_PASSWORD)
|
||||||
|
|
||||||
|
COOKIE_NAME = "manga_session"
|
||||||
|
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 дней
|
||||||
|
|
||||||
|
def _compute_token() -> str:
|
||||||
|
"""Стабильный токен сессии, производный от credentials."""
|
||||||
|
return _hmac.new(
|
||||||
|
AUTH_PASSWORD.encode(),
|
||||||
|
AUTH_LOGIN.encode(),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
_VALID_TOKEN: str = _compute_token() if AUTH_ENABLED else ""
|
||||||
|
|
||||||
|
# Пути, доступные без авторизации
|
||||||
|
_AUTH_EXEMPT = {"/api/login", "/api/auth/check", "/api/logout"}
|
||||||
|
|
||||||
app = FastAPI(title="Manga Downloader API")
|
app = FastAPI(title="Manga Downloader API")
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def auth_middleware(request: Request, call_next):
|
||||||
|
"""Проверяет авторизацию для всех /api/* эндпоинтов."""
|
||||||
|
if not AUTH_ENABLED:
|
||||||
|
return await call_next(request)
|
||||||
|
path = request.url.path
|
||||||
|
# Пропускаем статику и исключения
|
||||||
|
if not path.startswith("/api") or path in _AUTH_EXEMPT:
|
||||||
|
return await call_next(request)
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if token != _VALID_TOKEN:
|
||||||
|
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
# ── WebSocket менеджер ────────────────────────
|
# ── WebSocket менеджер ────────────────────────
|
||||||
|
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
@@ -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:
|
||||||
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():
|
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:
|
||||||
# Отправляем начальный снимок состояния
|
# Отправляем начальный снимок состояния
|
||||||
|
|||||||
27
src/state.py
27
src/state.py
@@ -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=?
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user