upd
This commit is contained in:
352
PLAN_MULTI_SOURCE.md
Normal file
352
PLAN_MULTI_SOURCE.md
Normal file
@@ -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>` со списком всех доступных источников. Без выбора источника кнопка «Добавить» неактивна.
|
||||||
|
|
||||||
|
После добавления домен 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 для редактирования настроек нет.
|
||||||
|
|
||||||
|
```python
|
||||||
|
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 + предупреждение + ручной выбор для неизвестных доменов
|
||||||
|
- Диалог смены источника с предупреждением о перепривязке домена
|
||||||
|
- Вкладка «Настройки → Источники»
|
||||||
|
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Manga Downloader</title>
|
<title>Manga Downloader</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"/>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<style>
|
<style>
|
||||||
body { background: #0f1117; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; }
|
body { background: #0f1117; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; }
|
||||||
@@ -61,9 +62,9 @@
|
|||||||
<!-- Login screen -->
|
<!-- Login screen -->
|
||||||
<div id="login-screen">
|
<div id="login-screen">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="flex items-center gap-3 mb-8 justify-center">
|
<div class="flex flex-col items-center gap-2 mb-8 justify-center">
|
||||||
<span class="text-3xl">📚</span>
|
<img src="/static/logo.png" alt="Manga Downloader" class="h-16 w-auto">
|
||||||
<h1 class="text-xl font-bold text-white">Manga Downloader</h1>
|
<span class="text-white font-bold text-xl tracking-wide">Manga Downloader</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -87,8 +88,8 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="border-b border-gray-800 px-6 py-4 flex items-center justify-between sticky top-0 z-50" style="background:#0f1117ee;backdrop-filter:blur(12px)">
|
<header class="border-b border-gray-800 px-6 py-4 flex items-center justify-between sticky top-0 z-50" style="background:#0f1117ee;backdrop-filter:blur(12px)">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-2xl">📚</span>
|
<img src="/static/logo.png" alt="Manga Downloader" class="h-10 w-auto">
|
||||||
<h1 class="text-xl font-bold text-white">Manga Downloader</h1>
|
<span class="text-white font-semibold text-lg tracking-wide">Manga Downloader</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div id="ws-status" class="flex items-center gap-2 text-sm text-gray-400">
|
<div id="ws-status" class="flex items-center gap-2 text-sm text-gray-400">
|
||||||
@@ -412,6 +413,7 @@ window.fetch = async function(...args) {
|
|||||||
|
|
||||||
// ── WebSocket ────────────────────────────────
|
// ── WebSocket ────────────────────────────────
|
||||||
let ws, wsReconnectTimer;
|
let ws, wsReconnectTimer;
|
||||||
|
let _pingInterval = null;
|
||||||
|
|
||||||
function connectWS() {
|
function connectWS() {
|
||||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
@@ -421,14 +423,15 @@ function connectWS() {
|
|||||||
document.getElementById('ws-dot').className = 'w-2 h-2 rounded-full bg-green-400';
|
document.getElementById('ws-dot').className = 'w-2 h-2 rounded-full bg-green-400';
|
||||||
document.getElementById('ws-text').textContent = 'Подключено';
|
document.getElementById('ws-text').textContent = 'Подключено';
|
||||||
clearTimeout(wsReconnectTimer);
|
clearTimeout(wsReconnectTimer);
|
||||||
// Keepalive
|
// Keepalive — один интервал, предыдущий убираем
|
||||||
setInterval(() => { if(ws && ws.readyState===1) ws.send('ping'); }, 20000);
|
if(_pingInterval) clearInterval(_pingInterval);
|
||||||
|
_pingInterval = setInterval(() => { if(ws && ws.readyState === 1) ws.send('ping'); }, 20000);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (e) => {
|
ws.onclose = (e) => {
|
||||||
document.getElementById('ws-dot').className = 'w-2 h-2 rounded-full bg-red-500';
|
document.getElementById('ws-dot').className = 'w-2 h-2 rounded-full bg-red-500';
|
||||||
|
if(_pingInterval) { clearInterval(_pingInterval); _pingInterval = null; }
|
||||||
if(e.code === 4401) {
|
if(e.code === 4401) {
|
||||||
// Сессия истекла или не авторизован
|
|
||||||
document.getElementById('ws-text').textContent = 'Нет доступа';
|
document.getElementById('ws-text').textContent = 'Нет доступа';
|
||||||
showLoginScreen();
|
showLoginScreen();
|
||||||
return;
|
return;
|
||||||
@@ -449,6 +452,9 @@ function handleEvent(msg) {
|
|||||||
msg.mangas.forEach(m => { state.mangas[m.url] = m; });
|
msg.mangas.forEach(m => { state.mangas[m.url] = m; });
|
||||||
renderList();
|
renderList();
|
||||||
loadStats();
|
loadStats();
|
||||||
|
// Дополнительно запрашиваем свежие данные с сервера — на случай если
|
||||||
|
// пока WS был отключён, статусы изменились и события были потеряны
|
||||||
|
_refreshMangaList();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'manga_queued':
|
case 'manga_queued':
|
||||||
@@ -476,11 +482,15 @@ function handleEvent(msg) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'manga_start':
|
case 'manga_start':
|
||||||
if(state.mangas[msg.url]) {
|
if(!state.mangas[msg.url]) {
|
||||||
|
// Манга не в state — берём свежие данные с сервера
|
||||||
|
_refreshMangaList();
|
||||||
|
} else {
|
||||||
state.mangas[msg.url].status = 'downloading';
|
state.mangas[msg.url].status = 'downloading';
|
||||||
if(msg.started_at) state.mangas[msg.url].started_at = msg.started_at;
|
if(msg.started_at) state.mangas[msg.url].started_at = msg.started_at;
|
||||||
|
state.mangas[msg.url].finished_at = null;
|
||||||
|
renderList();
|
||||||
}
|
}
|
||||||
renderList();
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'manga_info':
|
case 'manga_info':
|
||||||
@@ -685,7 +695,6 @@ function switchTab(tab) {
|
|||||||
if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); }
|
if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); }
|
||||||
if(tab === 'settings') loadSources();
|
if(tab === 'settings') loadSources();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function updateNewsBadge() {
|
function updateNewsBadge() {
|
||||||
const badge = document.getElementById('news-unread-badge');
|
const badge = document.getElementById('news-unread-badge');
|
||||||
@@ -1513,11 +1522,6 @@ function _rowButtons(m) {
|
|||||||
title="${m.errors_count} проблем при загрузке"
|
title="${m.errors_count} проблем при загрузке"
|
||||||
style="background:#450a0a;color:#fca5a5;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">⚠️ ${m.errors_count}</button>`
|
style="background:#450a0a;color:#fca5a5;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">⚠️ ${m.errors_count}</button>`
|
||||||
: ''}
|
: ''}
|
||||||
${!isActive
|
|
||||||
? `<button onclick="event.stopPropagation(); openSwitchSourceModal('${u}')"
|
|
||||||
title="Сменить источник"
|
|
||||||
style="background:#1e3a2e;color:#6ee7b7;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">↔ Источник</button>`
|
|
||||||
: ''}
|
|
||||||
${isActive
|
${isActive
|
||||||
? `<button onclick="stopManga('${u}')" class="btn-danger" title="Остановить" style="background:#7c2d12;color:#fdba74">⏸</button>`
|
? `<button onclick="stopManga('${u}')" class="btn-danger" title="Остановить" style="background:#7c2d12;color:#fdba74">⏸</button>`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -1788,6 +1792,12 @@ function renderModalBody(data) {
|
|||||||
style="background:#0c1a2e;color:#93c5fd;border:1px solid #1e3a5f">
|
style="background:#0c1a2e;color:#93c5fd;border:1px solid #1e3a5f">
|
||||||
↺ Скачать заново
|
↺ Скачать заново
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
|
${data.status !== 'downloading' ? `
|
||||||
|
<button onclick="openSwitchSourceModal('${escHtml(data.url)}')"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
||||||
|
style="background:#0f2a1e;color:#6ee7b7;border:1px solid #1e3a2e">
|
||||||
|
↔ Сменить источник
|
||||||
|
</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2007,20 +2017,24 @@ async function saveRenameFolder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Init ─────────────────────────────────────
|
// ── Init ─────────────────────────────────────
|
||||||
|
async function _refreshMangaList() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/mangas');
|
||||||
|
if(!r.ok) return;
|
||||||
|
const mangas = await r.json();
|
||||||
|
mangas.forEach(m => { state.mangas[m.url] = m; });
|
||||||
|
renderList();
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
async function initApp() {
|
async function initApp() {
|
||||||
_initDeleteModal();
|
_initDeleteModal();
|
||||||
await loadStats();
|
await loadStats();
|
||||||
await loadSources();
|
await loadSources();
|
||||||
connectWS();
|
connectWS();
|
||||||
// Загружаем список манги
|
await _refreshMangaList();
|
||||||
try {
|
// Периодически синхронизируем список манг — подстраховка от потерянных WS событий
|
||||||
const r = await fetch('/api/mangas');
|
setInterval(_refreshMangaList, 20000);
|
||||||
if(r.ok) {
|
|
||||||
const mangas = await r.json();
|
|
||||||
mangas.forEach(m => { state.mangas[m.url] = m; });
|
|
||||||
renderList();
|
|
||||||
}
|
|
||||||
} catch(e) {}
|
|
||||||
setInterval(loadStats, 15000);
|
setInterval(loadStats, 15000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
frontend/static/favicon.png
Normal file
BIN
frontend/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/static/logo.png
Normal file
BIN
frontend/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 477 KiB |
Reference in New Issue
Block a user