Files
manga/ARCHITECTURE.md
2026-05-01 03:27:33 +03:00

773 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Manga Downloader — Архитектура и устройство проекта
## Содержание
1. [Общее описание](#1-общее-описание)
2. [Структура файлов](#2-структура-файлов)
3. [Стек технологий](#3-стек-технологий)
4. [Схема архитектуры](#4-схема-архитектуры)
5. [Модули бэкенда](#5-модули-бэкенда)
- [auth.py](#authpy)
- [browser.py](#browserpy)
- [scraper.py](#scraperpy)
- [exporter.py](#exporterpy)
- [state.py](#statepy)
- [worker.py](#workerpy)
- [api.py](#apipy)
- [cli.py](#clipy)
6. [База данных](#6-база-данных)
7. [REST API](#7-rest-api)
8. [WebSocket протокол](#8-websocket-протокол)
9. [Фронтенд](#9-фронтенд)
10. [Жизненный цикл загрузки манги](#10-жизненный-цикл-загрузки-манги)
11. [Параллельная загрузка](#11-параллельная-загрузка)
12. [Конфигурация](#12-конфигурация)
13. [Docker-инфраструктура](#13-docker-инфраструктура)
---
## 1. Общее описание
Приложение скачивает мангу с сайтов типа readmanga.ru, обходя JS-защиту (DDoS-Guard, антибот) с помощью управляемого браузера Chromium. Поддерживает два режима работы:
- **Веб-интерфейс** — FastAPI-сервер + SPA на чистом JS (порт 8000). Позволяет управлять загрузками через браузер с realtime-прогрессом через WebSocket. Требует входа — поддерживает многопользовательский режим с ролями `admin` / `user`.
- **CLI** — консольные команды `download` и `analyze` для запуска через `docker compose run`.
---
## 2. Структура файлов
```
manga/
├── src/ # Весь бэкенд-код (Python-пакет)
│ ├── __init__.py
│ ├── api.py # FastAPI-приложение, REST + WebSocket
│ ├── auth.py # Хеширование паролей, генерация токенов сессий
│ ├── browser.py # Обёртка над Playwright/Chromium
│ ├── cli.py # CLI-команды (click)
│ ├── downloader.py # (legacy, не используется в web-режиме)
│ ├── exporter.py # Экспорт CBZ / PDF / EPUB
│ ├── scraper.py # Парсер страниц readmanga.ru
│ ├── state.py # SQLite ORM (StateDB)
│ └── worker.py # Асинхронный воркер загрузки
├── frontend/
│ └── index.html # SPA — весь фронтенд в одном файле
├── output/ # Смонтированная папка с CBZ/PDF/EPUB
│ └── <Название манги>/
│ ├── v01_ch0001.0.cbz
│ └── ...
├── state/ # Смонтированная папка с состоянием
│ ├── progress.db # SQLite база данных
│ └── manga.log # Логи (ротация по 10 МБ)
├── Dockerfile # Сборка образа
├── docker-compose.yml # Конфигурация сервисов
├── requirements.txt # Python-зависимости
├── debug_site.py # Утилита отладки (скриншот страницы)
├── debug_cdn.py # Утилита отладки CDN-запросов
├── analyze_speed.py # Анализ скорости загрузки
├── README.md # Краткое руководство пользователя
└── ARCHITECTURE.md # Этот файл
```
---
## 3. Стек технологий
| Компонент | Библиотека | Назначение |
|-----------|------------|------------|
| Браузер | `playwright==1.44.0` (Chromium) | Открытие защищённых JS-страниц |
| Web-фреймворк | `fastapi==0.111.0` + `uvicorn` | REST API и WebSocket |
| БД | SQLite (stdlib `sqlite3`) | Хранение состояния |
| CLI | `click==8.1.7` | Консольные команды |
| Изображения | `Pillow==10.3.0` | Открытие/конвертация для PDF |
| PDF | `img2pdf==0.5.1` (fallback: Pillow) | Склейка изображений в PDF |
| EPUB | `ebooklib==0.18` | Сборка EPUB3-файла |
| Расписание | `croniter==3.0.3` | Парсинг cron-выражений для планировщика |
| Логирование | `loguru==0.7.2` | Удобные форматированные логи |
| Фронтенд | Tailwind CSS (CDN) + Vanilla JS | SPA без сборщика |
---
## 4. Схема архитектуры
```
Браузер пользователя
│ HTTP / WebSocket (:8000)
┌─────────────────────────────────┐
│ FastAPI (api.py) │
│ │
│ Auth middleware (cookie/session)│
│ REST endpoints WebSocket /ws │
│ │ │ │
│ asyncio.Queue ws_manager │
│ │ (broadcast) │
│ ▼ ▲ │
│ queue_worker() │ │
│ │ events │
│ ▼ │ │
│ download_manga() ────┘ │ ◄── worker.py
│ │ │
└───────┼─────────────────────────┘
├─► BrowserManager (browser.py)
│ └─► Playwright Chromium
│ │
│ ┌───────────┴──────────┐
│ ▼ ▼
│ get_manga_info() get_chapter_images_and_download()
│ (scraper.py) (scraper.py)
├─► export() (exporter.py) → CBZ / PDF / EPUB
└─► StateDB (state.py) → progress.db
```
---
## 5. Модули бэкенда
### auth.py
**Отвечает за:** хеширование паролей и генерацию токенов сессий.
**Константы:**
```python
COOKIE_NAME = "manga_session"
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 дней
```
**Функции:**
- `hash_password(password)``str` — хеширует пароль методом PBKDF2-SHA256, 260 000 итераций, случайная соль. Формат результата: `pbkdf2:260000:<salt_hex>:<key_hex>`.
- `verify_password(password, hashed)``bool` — проверяет пароль по хешу через `hmac.compare_digest` (защита от timing-атак).
- `generate_session_token()``str` — генерирует 48-байтный URL-safe токен (`secrets.token_urlsafe`).
---
### browser.py
**Отвечает за:** запуск и управление Playwright Chromium.
**Ключевые детали:**
- `BrowserManager` — контекстный менеджер (`async with BrowserManager() as bm`), запускает/останавливает браузер.
- `new_page()` — создаёт новый `BrowserContext` + `Page`. Каждый контекст независим (отдельные куки, заголовки).
- Браузер запускается с антидетект-настройками:
- `--disable-blink-features=AutomationControlled`
- JavaScript-патч `STEALTH_JS`: скрывает `navigator.webdriver`, подставляет плагины и языки.
- Реалистичный `User-Agent` (Chrome 124 Linux).
- Заголовки `Accept-Language: ru-RU`, `Referer: https://3.readmanga.ru/` — сервер возвращает 404 без Referer.
- `shm_size: 2gb` в Docker — Chromium требует shared memory для рендеринга.
---
### scraper.py
**Отвечает за:** парсинг страниц манги и скачивание изображений глав.
#### Модели данных
```python
@dataclass
class Chapter:
title: str # "Том 1, Глава 5"
url: str # полный URL главы
number: float # 5.0 (из data-num / 10)
volume: int # 1
@dataclass
class MangaInfo:
title: str # русский тайтл (для имени папки)
url: str
chapters: list[Chapter]
pub_status: str # "completed" / "ongoing" / "unknown"
title_ru: str # чистый русский тайтл
title_full: str # полный тайтл со страницы
```
#### `get_manga_info(page, url)`
1. Открывает страницу манги через `_navigate()` (3 попытки, задержка 3×N сек).
2. Извлекает тайтл: сначала из DOM (`.names .name`), fallback — парсинг `<title>` (убирает «Манга», «онлайн», английские названия в скобках).
3. Извлекает `pub_status` по тексту `.elem_status .value` (ищет «завершён» / «продолжается»).
4. Раскрывает полный список глав кнопкой «Все главы» (`_expand_chapters`).
5. Парсит главы из `#chapters-list tr.item-row``td[data-num]` (num/10 = номер главы, `data-vol` = том). Fallback: `<a href*="/vol">`.
#### `get_chapter_images_and_download(page, chapter_url, dest_dir, ...)`
Самая сложная функция — обходит CDN-защиту изображений:
1. Открывает главу с параметром `?mtr=1` (флаг согласия 18+).
2. Устанавливает `page.route("**/*", route_handler)` — перехватывает все сетевые запросы.
3. Ждёт `readerInit` в JS-коде страницы — там содержится массив URL всех изображений.
4. Извлекает URL из `readerInit` через JS-eval (`_extract_images_from_js`). Fallback: ищет `img.manga-page` в DOM.
5. Перехватывает img-запросы через `route.fetch()` — запрос идёт браузерным стеком (правильные `Sec-Fetch-*`, cookies DDoS-Guard). Сохраняет байты в памяти.
6. Листает читалку клавишей `ArrowRight` — читалка подгружает страницы лениво.
7. Матчит перехваченные URL с индексами из `readerInit` (основной путь + fallback по имени файла).
8. Сохраняет в `dest_dir/0000.jpg`, `0001.png`, ...
**CDN-фильтр:** принимает только запросы к `one-way.work`, `staticfa.`, `cdnmanga`, `reimg`. Отклоняет статику сайта (`resrmr.`, `/static/`).
---
### exporter.py
**Отвечает за:** сборку файлов из набора изображений с встраиванием метаданных.
#### `MangaMeta` — датакласс метаданных
Передаётся в `export()` из воркера. Поля:
| Поле | Описание |
|------|---------|
| `series` | Название серии (title_ru) |
| `series_full` | Полное название со страницы (title_full) |
| `chapter_title` | Название главы |
| `number` | Номер главы (float) |
| `volume` | Том |
| `chapters_total` | Всего глав (для completed — записывается в Count) |
| `pub_status` | `completed` / `ongoing` / `unknown` |
| `source_url` | URL источника |
| `language` | `ru` |
#### Форматы и метаданные
| Формат | Реализация | Метаданные |
|--------|------------|-----------|
| **CBZ** | `zipfile` + `ComicInfo.xml` первым файлом | Anansi v2 schema: `Series`, `Number`, `Volume`, `Count` (если completed), `LanguageISO`, `Manga=YesAndRightToLeft`, `Web` |
| **PDF** | `img2pdf` (fallback: Pillow) + `pypdf` post-processing | PDF `/Info` (`/Title`, `/Subject`) + XMP Dublin Core (`dc:title`, `dc:description`, `dc:language`, `dc:source`) |
| **EPUB** | `ebooklib` | Dublin Core, `calibre:series` + `calibre:series_index`, EPUB3 `belongs-to-collection` + `group-position` |
`_patch_pdf_meta()` открывает готовый PDF через `pypdf`, добавляет метаданные и перезаписывает файл. Если `pypdf` не установлен — молча пропускает (graceful degradation).
---
### state.py
**Отвечает за:** всё взаимодействие с SQLite БД.
#### Класс `StateDB`
Подключение: `sqlite3.connect(path, check_same_thread=False)`.
#### Таблицы
**`mangas`** — одна строка на мангу:
| Колонка | Тип | Описание |
|---------|-----|---------|
| `url` | TEXT UNIQUE | URL страницы манги |
| `title` / `title_ru` / `title_full` | TEXT | Варианты названия |
| `pub_status` | TEXT | `completed` / `ongoing` / `unknown` |
| `auto_update` | INTEGER | 0/1 — включено ли авто-обновление |
| `status` | TEXT | `queued` / `downloading` / `done` / `failed` / `stopped` |
| `format` | TEXT | `cbz` / `pdf` / `epub` / `all` |
| `chapters_total` | INTEGER | Кол-во глав (из scraper) |
| `chapters_done` | INTEGER | Денормализованный счётчик¹ |
| `last_checked_at` | TEXT | Время последней проверки новых глав |
| `folder_name` | TEXT | Кастомное имя папки на диске (NULL → вычисляется из `title_ru`) |
| `source_id` | INTEGER | FK → sources.id — источник манги |
| `added_by` | INTEGER | FK → users.id — кто добавил мангу |
¹ API всегда пересчитывает `chapters_done` запросом `SELECT COUNT(*) FROM chapters WHERE status='done'`.
**`chapters`** — одна строка на главу:
| Колонка | Тип | Описание |
|---------|-----|---------|
| `chapter_url` | TEXT UNIQUE | URL главы |
| `manga_url` | TEXT | FK → mangas.url |
| `number` | REAL | Номер главы (5.0) |
| `volume` | INTEGER | Том |
| `status` | TEXT | `pending` / `done` / `failed` |
| `pages_total` / `pages_done` | INTEGER | Прогресс страниц |
| `output_cbz` / `output_pdf` / `output_epub` | TEXT | Путь к файлу |
**`history`** — лог событий:
| `event_type` | Описание |
|---|---|
| `downloaded` | Глава успешно скачана |
| `auto_downloaded` | Скачана в режиме авто-обновления |
| `new_chapter_found` | Найдена новая глава при проверке |
| `check_started` / `check_done` | Начало/конец проверки обновлений |
**`sources`** — источники (определяются в коде приложения):
| Колонка | Тип | Описание |
|---------|-----|---------|
| `slug` | TEXT UNIQUE | Идентификатор источника (`readmanga`) |
| `display_name` | TEXT | Отображаемое название |
| `settings` | TEXT | JSON-настройки источника |
**`source_domains`** — домены, привязанные к источникам:
| Колонка | Тип | Описание |
|---------|-----|---------|
| `source_id` | INTEGER | FK → sources.id |
| `domain` | TEXT UNIQUE | Домен без схемы и www (`readmanga.ru`) |
При первом запуске домены ReadManga автоматически засеиваются из списка `_DEFAULT_READMANGA_DOMAINS` в коде.
**`users`** — пользователи:
| Колонка | Тип | Описание |
|---------|-----|---------|
| `id` | INTEGER PK | |
| `username` | TEXT UNIQUE | Логин |
| `password` | TEXT | Хеш пароля (pbkdf2:iterations:salt:key) |
| `role` | TEXT | `admin` / `user` |
| `is_env_admin` | INTEGER | 1 — системный администратор из `AUTH_LOGIN`/`AUTH_PASSWORD`; его пароль нельзя изменить через интерфейс |
| `created_at` / `updated_at` | TEXT | ISO-8601 timestamp |
**`sessions`** — активные сессии:
| Колонка | Тип | Описание |
|---------|-----|---------|
| `token` | TEXT PK | URL-safe токен (48 байт) |
| `user_id` | INTEGER | FK → users.id ON DELETE CASCADE |
| `created_at` / `expires_at` | TEXT | Сессия действительна 30 дней |
#### Ключевые методы
- `add_manga(url, fmt, source_id, added_by)``bool` — добавляет, возвращает `False` если уже есть.
- `upsert_chapter(...)` — INSERT OR UPDATE (по `chapter_url`).
- `chapter_status(chapter_url)``str | None`.
- `sync_chapters_done(url)` — пересчитывает и сохраняет `chapters_done` из таблицы chapters.
- `get_autos()` — манги с `auto_update=1` не в статусе `downloading`.
- `update_manga_meta_fields(url, title_ru, title_full)` — обновляет пользовательские метаданные (название), не меняет папку.
- `set_folder_name(url, folder_name)` — устанавливает кастомное имя папки.
- `create_user(username, hashed_password, role, is_env_admin)``dict` — создаёт пользователя.
- `get_user_by_id(id)` / `get_user_by_username(username)``dict | None`.
- `get_all_users()``list[dict]` — все пользователи без поля `password`.
- `update_user(user_id, **kwargs)` — обновляет разрешённые поля (`username`, `password`, `role`).
- `delete_user(user_id)` — удаляет пользователя и все его сессии.
- `create_session(token, user_id, expires_at)` — создаёт сессию.
- `get_session(token)``dict | None` — возвращает только не истёкшие сессии.
- `cleanup_expired_sessions()``int` — удаляет истёкшие сессии.
---
### worker.py
**Отвечает за:** асинхронное скачивание манги с параллельными главами.
#### `download_manga(url, fmt, resume, is_update, on_event, chapter_concurrency)`
Полный цикл скачивания одной манги:
```
1. update_manga_status → "downloading"
2. BrowserManager.__aenter__
3. get_manga_info() → MangaInfo
4. update_manga_info() в БД
5. upsert_chapter() для каждой главы
6. Определение папки:
├── если db.get_manga(url)["folder_name"] задан → использует его
└── иначе → _safe_name(title_ru or title)
7. Делим главы:
├── to_skip (status == "done" и resume=True)
└── to_download (всё остальное)
8. Отправляем chapter_skipped события для to_skip
9. asyncio.Semaphore(chapter_concurrency)
10. asyncio.gather(*[process_chapter(ch) for ch in to_download])
11. sync_chapters_done() → update_manga_status → "done"
```
#### `process_chapter(ch)` (внутренняя корутина)
```
async with sem: # ограничение параллельности
1. Повторная проверка chapter_status (race condition guard)
2. ctx.new_page() — новая вкладка в общем контексте
3. get_chapter_images_and_download() → list[Path]
4. export() для каждого формата
5. mark_done() / mark_failed()
6. add_history()
7. chapter_done event
finally: ch_page.close()
```
#### Потокобезопасность
`db_lock = asyncio.Lock()` — все обращения к SQLite через `await db_call(fn, *args)`. Это необходимо, так как несколько корутин работают одновременно, а `sqlite3` не является asyncio-safe.
`counter_lock = asyncio.Lock()` — атомарный инкремент счётчика `chapters_done` для корректных данных в событиях прогресса.
#### `check_for_updates(url, on_event)`
Проверяет новые главы: сравнивает список глав из scraper с известными в БД. Возвращает список новых `chapter_url`.
---
### api.py
**Отвечает за:** FastAPI-приложение — HTTP-сервер, аутентификация, очередь загрузок, планировщик обновлений.
#### Auth-зависимости (Depends)
```python
get_current_user(request) # читает токен из cookie, валидирует сессию → dict пользователя
require_admin(user) # get_current_user + проверка role == "admin"
_check_manga_access(manga, user) # admin: полный доступ; user: только свои манги
```
Все endpoint'ы (кроме `/api/login` и `/api/auth/check`) требуют валидной сессии.
#### Глобальное состояние
```python
download_queue: asyncio.Queue # очередь job'ов {url, fmt, is_update}
active_tasks: dict[str, asyncio.Task] # url → текущая Task загрузки
ws_manager: ConnectionManager # set активных WebSocket-соединений
```
#### Вспомогательные функции
- `_safe_name(s)` — транслитерирует строку в безопасное имя папки (strip спецсимволов, пробелы → `_`, max 80 символов).
- `_manga_folder(m)` — возвращает `Path` к папке манги: если `m["folder_name"]` задан — использует его, иначе вычисляет из `title` через `_safe_name()`.
- `_broadcast_queue_positions()` — отправляет всем WS-клиентам событие `queue_positions`. Вызывается при любом изменении очереди.
#### Жизненный цикл при старте (`startup_event`)
1. Синхронизирует `sources` из кода реестра с БД (`sync_sources`).
2. Авто-мигрирует `source_id` для манг без него (`migrate_manga_sources`).
3. Удаляет истёкшие сессии (`cleanup_expired_sessions`).
4. **Bootstrap-admin:** если таблица `users` пуста — создаёт пользователя из `AUTH_LOGIN`/`AUTH_PASSWORD` с ролью `admin` и флагом `is_env_admin=True`.
5. Запускает `queue_worker()` и `update_scheduler()` как фоновые Task.
6. Восстанавливает незавершённые задачи из БД (status `queued`/`downloading` → снова в очередь).
#### `queue_worker()`
Последовательно извлекает задачи из `download_queue`. На каждую создаёт `asyncio.Task` через `download_manga()`, сохраняет в `active_tasks`. Обрабатывает `CancelledError` (stop/prioritize).
#### `update_scheduler()`
Через 5 минут после старта, затем по расписанию `UPDATE_SCHEDULE` (cron-синтаксис):
- Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается.
- Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него.
- Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**.
#### `_enrich_manga(m, db)`
Вспомогательная функция: обогащает строку из `mangas` реальными данными:
- `chapters_done``COUNT(*)` из таблицы `chapters` (не денормализованная колонка).
- `size_bytes` / `size_human` — размер папки на диске.
- `is_active` — есть ли Task в `active_tasks`.
- `errors_count` — сумма failed и partial глав.
---
### cli.py
**Отвечает за:** консольные команды для запуска без веб-интерфейса.
#### Команды
**`download <URL>`**
```
--format / -f cbz|pdf|epub|all (default: cbz)
--chapters / -c диапазон: "1-10", "5", "1,3,7"
--output / -o папка вывода
--resume пропускать скачанные (default: True)
--force / -F игнорировать БД, скачать заново
--concurrency параллельных загрузок (default: 4)
--verbose / -v DEBUG-вывод
```
**`analyze <URL>`**
Открывает страницу, выводит список всех глав и метаданные без скачивания.
CLI использует те же `BrowserManager`, `scraper`, `exporter`, `StateDB`, что и web-режим, но запускает их напрямую через `asyncio.run()`.
---
## 6. База данных
**Файл:** `/app/state/progress.db` (монтируется из `./state/progress.db` на хосте).
**Открытие:** `sqlite3.connect(path, check_same_thread=False)` — один объект `StateDB` на запрос/worker, не shared между потоками.
**Миграции:** при каждом запуске `StateDB._init()` выполняет `ALTER TABLE ... ADD COLUMN` в блоке `try/except` — безопасно добавляет новые колонки в старые БД.
**Важно:** колонка `mangas.chapters_done` является устаревшим денормализованным счётчиком. В API и WebSocket snapshot всегда используется динамический подсчёт из таблицы `chapters`. Это важно, чтобы не показывать некорректные числа (> chapters_total) после перезапусков.
---
## 7. REST API
Базовый URL: `http://localhost:8000`
Все endpoint'ы, кроме `/api/login` и `/api/auth/check`, требуют валидной сессии (cookie `manga_session`). Endpoint'ы с пометкой `[admin]` доступны только пользователям с ролью `admin`.
### Аутентификация
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/auth/check` | Проверить текущую сессию. Возвращает `{authenticated, user: {id, username, role, is_env_admin}}` |
| `POST` | `/api/login` | Войти `{login, password}`. Устанавливает cookie сессии на 30 дней |
| `POST` | `/api/logout` | Выйти. Удаляет сессию из БД и cookie |
### Управление пользователями `[admin]`
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/users` | Список всех пользователей (без паролей) |
| `POST` | `/api/users` | Создать пользователя `{username, password, role}` |
| `PATCH` | `/api/users/{user_id}` | Изменить `{username?, password?, role?}`. Для `is_env_admin=1` смена пароля заблокирована. Обычный пользователь может менять только свой пароль |
| `DELETE` | `/api/users/{user_id}` | Удалить пользователя. Системного администратора и последнего admin удалить нельзя |
### Источники
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/sources` | Список источников с доменами и настройками |
| `GET` | `/api/resolve-source?url=` | Определить источник по URL манги |
| `POST` | `/api/sources/{id}/domains` `[admin]` | Добавить домен к источнику `{domain}` |
| `DELETE` | `/api/sources/{id}/domains/{domain}` `[admin]` | Удалить домен у источника |
### Манги
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/mangas` | Список всех манг с реальными счётчиками |
| `GET` | `/api/mangas/detail?url=` | Детали: главы, файлы, статистика ошибок |
| `POST` | `/api/queue` | Добавить мангу(и) в очередь `{urls: [...], format: "cbz"}` |
| `POST` | `/api/mangas/stop?url=` | Остановить загрузку |
| `POST` | `/api/mangas/resume?url=` | Возобновить |
| `POST` | `/api/mangas/prioritize?url=` `[admin]` | Переместить в начало очереди (вытесняет текущую) |
| `POST` | `/api/mangas/retry_errors?url=` | Сбросить failed/partial главы → pending |
| `POST` | `/api/mangas/auto_update?url=&enabled=` | Вкл/выкл авто-обновление |
| `POST` | `/api/mangas/check_now?url=` | Немедленно проверить новые главы |
| `POST` | `/api/mangas/update_meta` | Изменить `title_ru`/`title_full` и применить к метаданным файлов `{url, title_ru, title_full}` |
| `POST` | `/api/mangas/rename_folder` | Переименовать папку на диске `{url, folder_name}` |
| `POST` | `/api/mangas/refresh_meta?url=` | Обновить метаданные в уже скачанных файлах |
| `POST` | `/api/mangas/force_redownload?url=` `[admin]` | Сбросить все главы и поставить в очередь заново |
| `POST` | `/api/mangas/switch-source` `[admin]` | Сменить источник манги `{manga_url, source_id}` |
| `DELETE` | `/api/mangas?url=` `[admin]` | Удалить мангу из БД |
### Прочее
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) |
| `GET` | `/api/history?limit=&manga_url=` | История событий |
| `GET` | `/api/news?limit=` | Только события `downloaded`/`auto_downloaded` |
| `WS` | `/ws` | WebSocket для realtime-обновлений |
---
## 8. WebSocket протокол
### Сервер → Клиент (события)
| `type` | Данные | Когда |
|--------|--------|-------|
| `snapshot` | `{mangas: [...]}` | При подключении — полный список с реальными счётчиками |
| `manga_queued` | `{url, format}` | Добавлена в очередь |
| `manga_start` | `{url}` | Начало загрузки |
| `manga_info` | `{url, title, chapters_total, pub_status, ...}` | Получены метаданные |
| `manga_done` | `{url, chapters_done, chapters_total}` | Загрузка завершена |
| `manga_failed` | `{url, error}` | Ошибка |
| `manga_stopped` | `{url}` | Остановлена |
| `manga_prioritized` | `{url, preempted_url}` | Приоритет изменён |
| `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления |
| `manga_meta_updated` | `{url, title, title_ru, title_full}` | Метаданные отредактированы пользователем |
| `manga_folder_renamed` | `{url, folder_name}` | Папка переименована |
| `queue_positions` | `{positions: {url: номер}}` | Актуальные позиции в очереди — при любом изменении очереди |
| `chapter_start` | `{url, chapter_url, chapter_number, chapters_done, chapters_total}` | Начало главы |
| `chapter_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова |
| `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) |
| `chapter_failed` | `{url, chapter_url, error?}` | Ошибка главы |
| `page_done` | `{url, chapter_url, pages_done, pages_total}` | Страница скачана |
| `check_started` / `check_done` | `{url, new_chapters?}` | Проверка обновлений |
| `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава |
| `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления |
| `meta_refreshed` | `{url, updated, failed}` | Метаданные файлов обновлены |
| `source_domain_added` | `{source_id, domain}` | Добавлен домен к источнику |
| `source_domain_removed` | `{source_id, domain}` | Домен удалён у источника |
### Клиент → Сервер
| Сообщение | Действие |
|-----------|---------|
| `"ping"` | Сервер отвечает `{"type": "pong"}` — keepalive |
---
## 9. Фронтенд
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~2350 строк).
**Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика).
### Экран входа
При загрузке приложение вызывает `GET /api/auth/check`. Если сессия невалидна — показывается экран входа (`#login-screen`). После успешного `POST /api/login` сессия сохраняется в cookie, экран входа скрывается.
`state.currentUser` содержит `{id, username, role, is_env_admin}` — используется для скрытия/показа элементов интерфейса в зависимости от роли.
### Архитектура состояния
```javascript
const state = {
mangas: {}, // url → объект манги (из snapshot/API + WS-обновления)
chapters: {}, // url → массив глав (загружается по запросу в модалке)
currentUser: null, // {id, username, role, is_env_admin}
sources: [], // список источников
};
```
### Поток данных
```
DOMContentLoaded
├─ checkAuth() ──────────────────────► GET /api/auth/check
│ │ (если ok → initApp())
│ └─ showLoginScreen()
└─ initApp()
├─ loadStats() ──────────────────► GET /api/stats
├─ connectWS() ──────────────────► WS /ws
│ │
│ └─ snapshot event ───────► state.mangas = enriched list
│ + live events ───────► state.mangas[url].* обновляется
└─ fetch('/api/mangas') ──────────► state.mangas = полный список
```
**Важный нюанс порядка:** `connectWS()` не awaited, поэтому snapshot может прийти после `fetch('/api/mangas')` и перезаписать правильные значения устаревшими из БД. Именно поэтому WS snapshot тоже использует `_enrich_manga()` с пересчётом из таблицы `chapters`.
### Вкладки
- **Манга** — список всех манг, добавление, управление.
- **Новости** — события `downloaded`/`auto_downloaded` (что скачалось).
- **История** — все события из таблицы `history`.
- **Настройки** — управление источниками, пользователями (только admin), смена своего пароля.
### Вкладка «Настройки»
При открытии загружает:
- Список источников (`GET /api/sources`) с управлением доменами (только admin).
- Список пользователей (`GET /api/users`, только admin) — в разделе **Пользователи**.
- Раздел **Сменить пароль** — скрыт для системного администратора (`is_env_admin=true`).
**Управление пользователями (только admin):**
- Создание: кнопка «+ Добавить» → модалка с логином, паролем, ролью.
- Редактирование: кнопка ✏️ → модалка. Для системного администратора (`is_env_admin`) поле пароля скрыто.
- Удаление: кнопка ✕ (недоступна для системного администратора и для самого себя).
- Системный администратор помечен иконкой 🔒 с тултипом.
### Модальное окно детали
Открывается кликом на строку манги. Загружает `GET /api/mangas/detail?url=` с полным списком глав, файлами на диске, статистикой ошибок. Содержит кнопки:
- **✏️ Редактировать название** — открывает модалку изменения `title_ru` / `title_full`. Папка не переименовывается, но метаданные всех файлов обновляются автоматически через `_do_refresh_meta`.
- **📁 Переименовать папку** — открывает модалку смены имени папки на диске (доступна при статусе не `downloading`). Физически переименовывает папку, обновляет пути в `chapters.output_*`, сохраняет `folder_name` в `mangas`.
- **🏷 Обновить метатеги** — принудительно обновляет метаданные в уже скачанных файлах (для манги в статусе `done`).
- **↺ Скачать заново** `[admin]` — сбрасывает все главы и ставит в очередь повторно.
### Карточки манги (кнопки)
| Кнопка | Условие отображения | Действие |
|--------|---------------------|---------|
| | всегда | Открыть детальное модальное окно |
| ⚠️ N | `errors_count > 0` | Открыть вкладку ошибок в модалке |
| ⏸ | `status` = `downloading` или `queued` | Остановить загрузку |
| ▶ | `status` = `stopped` или `failed` | Возобновить |
| 🚀 | `status` = `queued` (только в очереди, не активная) | Переместить в начало очереди |
| ✕ `[admin]` | всегда | Удалить |
### Позиции в очереди
Отображаются на карточке как «Позиция в очереди: N». Обновляются в реальном времени через событие `queue_positions` (не перерендер всего списка, а точечное обновление через `updateMangaRow`). Событие рассылается сервером при каждом изменении состояния очереди.
---
## 12. Конфигурация
### Переменные окружения
| Переменная | Default | Описание |
|------------|---------|---------|
| `AUTH_LOGIN` | — | Логин системного администратора. Создаётся при первом старте, если таблица `users` пуста |
| `AUTH_PASSWORD` | — | Пароль системного администратора. Для смены — изменить переменную и пересоздать контейнер |
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно |
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки (cron-синтаксис). Пример: `0 */6 * * *`. Если пусто — планировщик отключён |
| `UPDATE_INTERVAL_HOURS` | — | Устаревший аналог `UPDATE_SCHEDULE`: число часов → конвертируется в cron автоматически |
| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
### Пути (hardcoded в коде)
| Константа | Путь |
|-----------|------|
| `OUTPUT_DIR` | `/app/output` |
| `FRONTEND_DIR` | `/app/frontend` |
| `DB_PATH` | `/app/state/progress.db` |
| Лог | `/app/state/manga.log` (ротация 10 МБ) |
---
## 13. Docker-инфраструктура
### Dockerfile
```
FROM mcr.microsoft.com/playwright/python:v1.44.0-jammy
└── Ubuntu 22.04 + Python + все системные зависимости для Chromium
RUN pip install -r requirements.txt
RUN playwright install chromium --with-deps
CMD uvicorn src.api:app --host 0.0.0.0 --port 8000
```
### docker-compose.yml
```yaml
volumes:
- ./output:/app/output # CBZ/PDF/EPUB файлы
- ./state:/app/state # БД и логи
ports:
- "8000:8000" # Веб-интерфейс
shm_size: "2gb" # Chromium требует shared memory
environment:
- UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron)
- AUTH_LOGIN=... # системный администратор
- AUTH_PASSWORD=...
restart: unless-stopped # Автоперезапуск при падении
```
### CLI-режим (через compose run)
```bash
# Скачать мангу без веб-интерфейса
docker compose run --rm --entrypoint "" manga \
python -m src.cli download https://3.readmanga.ru/magicheskaia_bitva --format cbz
# Анализ
docker compose run --rm --entrypoint "" manga \
python -m src.cli analyze https://3.readmanga.ru/magicheskaia_bitva
```
### Хранение данных
После остановки контейнера все данные сохраняются на хосте:
- `./output/` — скачанные файлы.
- `./state/progress.db` — состояние БД (что скачано, что в очереди, пользователи, сессии).
- `./state/manga.log` — логи.
При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь.