diff --git a/PLAN_MULTI_SOURCE.md b/PLAN_MULTI_SOURCE.md new file mode 100644 index 0000000..75cf2bc --- /dev/null +++ b/PLAN_MULTI_SOURCE.md @@ -0,0 +1,352 @@ +# План реализации: 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 рядом с `