diff --git a/Dockerfile b/Dockerfile index 4eee9ad..3063d37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,4 +19,4 @@ VOLUME ["/app/output", "/app/state"] # По умолчанию запускаем веб-сервер ENTRYPOINT [] -CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"] diff --git a/PLAN_MULTI_SOURCE.md b/PLAN_MULTI_SOURCE.md deleted file mode 100644 index 75cf2bc..0000000 --- a/PLAN_MULTI_SOURCE.md +++ /dev/null @@ -1,352 +0,0 @@ -# План реализации: Multi-Source архитектура - -Рефакторинг под систему плагинов-адаптеров: каждый источник — отдельный класс с унифицированным `Protocol`-интерфейсом. Новые таблицы `sources` / `source_domains` в БД, автоопределение источника по домену URL, CRUD-API для доменов и UI-компоненты во фронтенде. Существующий `scraper.py` становится адаптером `ReadmangaSource`. - ---- - -## 1. Архитектура системы источников - -**Организация**: `Protocol`-интерфейс + реестр (`SourceRegistry`) + slug-имена в коде. - -Создать `src/sources/` — пакет с адаптерами: - -``` -src/sources/ - __init__.py ← реестр + фабрика - base.py ← MangaSourceProtocol (Protocol-класс) - readmanga.py ← ReadmangaSource (перенесённый scraper.py) -``` - -### `base.py` — Protocol-интерфейс - -```python -class MangaSourceProtocol(Protocol): - slug: str # "readmanga" — уникальный код в коде - display_name: str # "ReadManga" — для UI - - async def get_manga_info(self, page, url) -> Optional[MangaInfo]: ... - async def get_chapter_images_and_download( - self, page, chapter_url, dest_dir, ... - ) -> list[Path]: ... -``` - -### `__init__.py` — реестр и резолвинг - -`SourceRegistry` — dict `slug → instance`. Список источников **определяется только в коде** — новый источник добавляется созданием нового класса и регистрацией в реестре. Через API управлять можно **только доменами**. - -Экспортирует: - -- `registry.get_by_slug(slug)` — по коду источника -- `registry.get_by_id(source_id, db)` — через БД: `sources.id → slug → экземпляр` -- `registry.all()` — полный список зарегистрированных источников (для синхронизации с БД и отображения в UI) -- `get_source_for_url(url, db)` — извлекает домен из URL, ищет в `source_domains`, возвращает адаптер или `None` (домен неизвестен) - -### `readmanga.py` — `ReadmangaSource` - -Класс с `slug = "readmanga"`. Весь текущий код `scraper.py` переезжает сюда без изменений. CDN-фильтр вынесен в атрибут `cdn_patterns: list[str]`, который можно переопределить настройками из `sources.settings` (JSON). Адаптер самодостаточен. - -### Добавление нового источника - -Создать файл `src/sources/mysource.py`, реализовать Protocol, зарегистрировать: - -```python -# src/sources/__init__.py -from .readmanga import ReadmangaSource -from .mysource import MySource - -registry = SourceRegistry([ - ReadmangaSource(), - MySource(), -]) -``` - -При следующем старте приложения `StateDB._sync_sources()` автоматически добавит запись нового источника в таблицу `sources` (если её ещё нет). Удалять источники из кода не рекомендуется без предварительной миграции манг. - ---- - -## 2. Изменения БД - -### Новые таблицы - -```sql -CREATE TABLE IF NOT EXISTS sources ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - slug TEXT UNIQUE NOT NULL, -- "readmanga" — совпадает с кодом - display_name TEXT NOT NULL, - settings TEXT DEFAULT '{}', -- JSON: cdn_patterns и др. - created_at TEXT -); - -CREATE TABLE IF NOT EXISTS source_domains ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_id INTEGER NOT NULL REFERENCES sources(id), - domain TEXT UNIQUE NOT NULL -- "readmanga.ru", "readmanga.live" -); -``` - -### Изменение таблицы `mangas` - -```sql -ALTER TABLE mangas ADD COLUMN source_id INTEGER REFERENCES sources(id); -``` - -Добавляется через существующий паттерн миграций в `StateDB._init()`. - -### Синхронизация источников с кодом (`_sync_sources`) - -При старте (в `_init()`) вызывается `_sync_sources(registry)`: -1. Для каждого источника из реестра — вставить запись в `sources` если ещё нет (по `slug`). -2. Обновить `display_name` если изменился. -3. **Не удалять** источники из БД даже если они убраны из реестра — только логировать предупреждение. - -### Авто-миграция существующих манг - -При старте пройтись по всем мангам с `source_id IS NULL`, определить домен из `url`, проставить `source_id` по совпадению в `source_domains`. Если домен не найден — оставить `NULL` (отобразится в UI как «источник не определён»). - -### Сидинг доменов ReadManga - -```python -DEFAULT_READMANGA_DOMAINS = [ - "readmanga.ru", "readmanga.live", "readmanga.me", "readmanga.io", - "3.readmanga.ru", -] -``` - -Вставляется однократно при первом старте (если нет ни одного домена для `readmanga`). - -### Новые методы `StateDB` - -- `get_source_by_domain(domain)` → `dict | None` -- `get_all_sources()` → `list[dict]` (с вложенными доменами) -- `add_domain(source_id, domain)` → `bool` -- `remove_domain(source_id, domain)` -- `set_manga_source(manga_url, source_id)` — меняет источник + привязывает домен URL к новому источнику (см. §3) - ---- - -## 3. Рефакторинг `scraper.py` и `worker.py` - -### `src/scraper.py` — shim для обратной совместимости - -После переноса кода в `ReadmangaSource`: - -```python -# src/scraper.py -from .sources.readmanga import ReadmangaSource as _src -from .sources.base import MangaInfo, Chapter - -_instance = _src() - -async def get_manga_info(page, url): - return await _instance.get_manga_info(page, url) - -async def get_chapter_images_and_download(page, chapter_url, dest_dir, **kw): - return await _instance.get_chapter_images_and_download(page, chapter_url, dest_dir, **kw) -``` - -Это позволяет не ломать `worker.py` и `cli.py` на переходном этапе. - -### `src/worker.py` — подключение реестра - -В `download_manga(url, fmt, ...)`: - -```python -from .sources import get_source_for_url - -source = get_source_for_url(url, db) -if source is None: - # Источник не определён — ошибка, уведомить через WS - await ws_broadcast({"type": "source_unknown", "url": url}) - return -``` - -Передавать `source` в `process_chapter()` и далее в функции скачивания. - -`check_for_updates()` — аналогично резолвит источник. - -### Смена источника + перепривязка домена - -```python -async def switch_source(manga_url: str, new_source_id: int, db: StateDB): - """Меняет источник манги и привязывает домен URL к новому источнику.""" - domain = extract_domain(manga_url) # извлечь домен из URL манги - old_domain_source = db.get_source_by_domain(domain) - - # Перепривязать домен к новому источнику - if old_domain_source: - db.remove_domain(old_domain_source["id"], domain) - db.add_domain(new_source_id, domain) - - # Сменить источник у манги - db.set_manga_source(manga_url, new_source_id) - - # Сбросить failed/partial главы → pending - db.reset_failed_chapters(manga_url) -``` - -Таким образом, при следующем добавлении манги с того же домена источник будет определён автоматически правильно. - ---- - -## 4. API эндпоинты - -**Создание и удаление источников через API недоступны** — источники определяются только в коде. - -### Источники (только чтение + управление доменами) - -| Метод | Путь | Описание | -|-------|------|----------| -| `GET` | `/api/sources` | Список всех источников с доменами | -| `POST` | `/api/sources/{id}/domains` | Добавить домен к источнику `{domain}` | -| `DELETE` | `/api/sources/{id}/domains/{domain}` | Удалить домен | -| `GET` | `/api/resolve-source?url=` | Определить источник по URL → `{source_id, slug, display_name} \| null` | - -### Управление мангой - -| Метод | Путь | Описание | -|-------|------|----------| -| `POST` | `/api/mangas/switch-source` | Сменить источник `{url, source_id}` (не во время загрузки) | - -### Pydantic-модели - -```python -class DomainAdd(BaseModel): - domain: str - -class SourceOut(BaseModel): - id: int - slug: str - display_name: str - domains: list[str] - settings: dict - -class SwitchSourceRequest(BaseModel): - url: str - source_id: int - # домен всегда перепривязывается автоматически -``` - ---- - -## 5. Изменения фронтенда - -### Диалог добавления манги - -1. После ввода URL (debounce 400 мс) → GET `/api/resolve-source?url=...` -2. **Источник найден** → показать badge «Источник: ReadManga» под полем ввода -3. **Источник неизвестен** → показать предупреждение: - > ⚠ Домен не распознан. Выберите источник вручную: - - Под предупреждением — `` со всеми источниками - - Статичное предупреждение под select (всегда видимо): «⚠ Домен `xyz.com` будет перепривязан к выбранному источнику. Это затронет все манги с этого домена.» - - Кнопка «Применить» → POST `/api/mangas/switch-source` - -### Новая вкладка «Настройки» - -Добавить четвёртую вкладку в навигацию. - -**Подраздел «Источники»** (единственный на данном этапе): - -``` -┌─ Источники ──────────────────────────────────────────┐ -│ Источники определяются в коде приложения. │ -│ Здесь можно управлять доменами для каждого источника│ -│ │ -│ ┌────────────────────────────────────────────────────┐│ -│ │ ReadManga slug: readmanga ││ -│ │ Домены: ││ -│ │ • readmanga.ru [✕] • readmanga.live [✕] ││ -│ │ • 3.readmanga.ru [✕] [+ добавить домен] ││ -│ └────────────────────────────────────────────────────┘│ -│ ┌────────────────────────────────────────────────────┐│ -│ │ Другой источник slug: other ││ -│ │ ... ││ -│ └────────────────────────────────────────────────────┘│ -└──────────────────────────────────────────────────────┘ -``` - -Inline-редактирование: -- `[+ добавить домен]` → inline `` + кнопка «✓» → POST `/api/sources/{id}/domains` -- `[✕]` рядом с доменом → DELETE `/api/sources/{id}/domains/{domain}` - -Кнопок «Создать источник» или «Удалить источник» **нет**. - ---- - -## 6. WebSocket события - -| `type` | Когда | Данные | -|--------|-------|--------| -| `source_domain_added` | POST /api/sources/{id}/domains | `{source_id, domain}` | -| `source_domain_removed` | DELETE /api/sources/{id}/domains/... | `{source_id, domain}` | -| `source_switched` | POST /api/mangas/switch-source | `{url, old_source_id, new_source_id, domain_rebound: true}` | -| `source_unknown` | Попытка загрузки манги без источника | `{url}` — фронт показывает уведомление | - ---- - -## 7. Решённые вопросы - -### 7.1 CDN-паттерны и настройки источника - -Каждый источник хранит свои технические настройки (CDN-паттерны и т.п.) **только в коде** внутри класса-адаптера. Поле `settings` в таблице `sources` не используется для пользовательского редактирования — оно остаётся зарезервированным для внутренних нужд адаптера. Никакого UI для редактирования настроек нет. - -```python -class ReadmangaSource: - slug = "readmanga" - display_name = "ReadManga" - cdn_patterns = ["one-way.work", "staticfa.", "cdnmanga", "reimg"] -``` - -### 7.2 Домен, уже привязанный к другому источнику - -При смене источника у манги перепривязка домена к новому источнику происходит **автоматически** без дополнительного подтверждения. Флаг `rebind_domain` не нужен. - -В UI рядом с `