# План реализации: 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 рядом с `