18 KiB
План реализации: 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-интерфейс
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, зарегистрировать:
# src/sources/__init__.py
from .readmanga import ReadmangaSource
from .mysource import MySource
registry = SourceRegistry([
ReadmangaSource(),
MySource(),
])
При следующем старте приложения StateDB._sync_sources() автоматически добавит запись нового источника в таблицу sources (если её ещё нет). Удалять источники из кода не рекомендуется без предварительной миграции манг.
2. Изменения БД
Новые таблицы
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
ALTER TABLE mangas ADD COLUMN source_id INTEGER REFERENCES sources(id);
Добавляется через существующий паттерн миграций в StateDB._init().
Синхронизация источников с кодом (_sync_sources)
При старте (в _init()) вызывается _sync_sources(registry):
- Для каждого источника из реестра — вставить запись в
sourcesесли ещё нет (поslug). - Обновить
display_nameесли изменился. - Не удалять источники из БД даже если они убраны из реестра — только логировать предупреждение.
Авто-миграция существующих манг
При старте пройтись по всем мангам с source_id IS NULL, определить домен из url, проставить source_id по совпадению в source_domains. Если домен не найден — оставить NULL (отобразится в UI как «источник не определён»).
Сидинг доменов ReadManga
DEFAULT_READMANGA_DOMAINS = [
"readmanga.ru", "readmanga.live", "readmanga.me", "readmanga.io",
"3.readmanga.ru",
]
Вставляется однократно при первом старте (если нет ни одного домена для readmanga).
Новые методы StateDB
get_source_by_domain(domain)→dict | Noneget_all_sources()→list[dict](с вложенными доменами)add_domain(source_id, domain)→boolremove_domain(source_id, domain)set_manga_source(manga_url, source_id)— меняет источник + привязывает домен URL к новому источнику (см. §3)
3. Рефакторинг scraper.py и worker.py
src/scraper.py — shim для обратной совместимости
После переноса кода в ReadmangaSource:
# 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, ...):
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() — аналогично резолвит источник.
Смена источника + перепривязка домена
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-модели
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. Изменения фронтенда
Диалог добавления манги
-
После ввода URL (debounce 400 мс) → GET
/api/resolve-source?url=... -
Источник найден → показать badge «Источник: ReadManga» под полем ввода
-
Источник неизвестен → показать предупреждение:
⚠ Домен не распознан. Выберите источник вручную:
Под предупреждением —
<select>со списком всех доступных источников. Без выбора источника кнопка «Добавить» неактивна.После добавления домен URL автоматически привязывается к выбранному источнику (бэкенд делает это в момент добавления манги).
Карточка манги
- Badge с
source.display_nameрядом с названием (серый, если источник не определён → «Источник неизвестен») - Кнопка «↔ Источник» — видима всегда, кроме статуса
downloading; открывает модал:- Текущий источник (или «не определён»)
<select>со всеми источниками- Статичное предупреждение под select (всегда видимо): «⚠ Домен
xyz.comбудет перепривязан к выбранному источнику. Это затронет все манги с этого домена.» - Кнопка «Применить» → POST
/api/mangas/switch-source
Новая вкладка «Настройки»
Добавить четвёртую вкладку в навигацию.
Подраздел «Источники» (единственный на данном этапе):
┌─ Источники ──────────────────────────────────────────┐
│ Источники определяются в коде приложения. │
│ Здесь можно управлять доменами для каждого источника│
│ │
│ ┌────────────────────────────────────────────────────┐│
│ │ ReadManga slug: readmanga ││
│ │ Домены: ││
│ │ • readmanga.ru [✕] • readmanga.live [✕] ││
│ │ • 3.readmanga.ru [✕] [+ добавить домен] ││
│ └────────────────────────────────────────────────────┘│
│ ┌────────────────────────────────────────────────────┐│
│ │ Другой источник slug: other ││
│ │ ... ││
│ └────────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────┘
Inline-редактирование:
[+ добавить домен]→ inline<input>+ кнопка «✓» → 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 для редактирования настроек нет.
class ReadmangaSource:
slug = "readmanga"
display_name = "ReadManga"
cdn_patterns = ["one-way.work", "staticfa.", "cdnmanga", "reimg"]
7.2 Домен, уже привязанный к другому источнику
При смене источника у манги перепривязка домена к новому источнику происходит автоматически без дополнительного подтверждения. Флаг rebind_domain не нужен.
В UI рядом с <select> источника отображается статичное предупреждение:
⚠ Домен
xyz.comбудет перепривязан к выбранному источнику. Это затронет все манги с этого домена.
Флаг rebind_domain в SwitchSourceRequest не нужен — бэкенд всегда перепривязывает домен.
7.3 Удалённые из кода источники
При старте логировать предупреждение для каждого источника в БД, которого нет в реестре. В UI такие манги показывают badge «Источник недоступен» красным цветом. Загрузка таких манг невозможна до смены источника.
8. Порядок реализации (этапы)
Этап 1 — БД (без ломки текущей логики)
- Добавить таблицы
sources,source_domainsвstate.py - Добавить колонку
source_idвmangas - Реализовать
_sync_sources(registry)+ сидинг readmanga-доменов - Авто-миграция существующих манг (проставить
source_idпо домену) - Новые методы
StateDB
Этап 2 — Адаптер + Реестр
- Создать
src/sources/пакет - Перенести
scraper.py→src/sources/readmanga.py(классReadmangaSource) - Реализовать
SourceRegistry,get_source_for_url() - Написать shim
src/scraper.py(обратная совместимость)
Этап 3 — Worker + API
- Подключить реестр в
worker.py - Добавить
switch_source()с перепривязкой домена - Реализовать API эндпоинты (только домены + switch)
- WS-события
Этап 4 — Фронтенд
- Badge источника на карточках манги
- Автоопределение при вводе URL + предупреждение + ручной выбор для неизвестных доменов
- Диалог смены источника с предупреждением о перепривязке домена
- Вкладка «Настройки → Источники»