Files
manga/PLAN_MULTI_SOURCE.md
2026-04-30 19:32:13 +03:00

18 KiB
Raw Blame History

План реализации: 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.pyReadmangaSource

Класс с 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):

  1. Для каждого источника из реестра — вставить запись в sources если ещё нет (по slug).
  2. Обновить display_name если изменился.
  3. Не удалять источники из БД даже если они убраны из реестра — только логировать предупреждение.

Авто-миграция существующих манг

При старте пройтись по всем мангам с 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 | 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:

# 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. Изменения фронтенда

Диалог добавления манги

  1. После ввода URL (debounce 400 мс) → GET /api/resolve-source?url=...

  2. Источник найден → показать badge «Источник: ReadManga» под полем ввода

  3. Источник неизвестен → показать предупреждение:

    ⚠ Домен не распознан. Выберите источник вручную:

    Под предупреждением — <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.pysrc/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 + предупреждение + ручной выбор для неизвестных доменов
  • Диалог смены источника с предупреждением о перепривязке домена
  • Вкладка «Настройки → Источники»