Compare commits

..

9 Commits

Author SHA1 Message Date
672e199d3a validation 2026-05-03 14:37:57 +03:00
0f8707fe93 retry 2026-05-03 14:12:25 +03:00
84b24b2b5b upd 2026-05-03 14:07:18 +03:00
bb6f2d67d8 upd 2026-05-03 13:37:21 +03:00
2cb244d973 upd 2026-05-03 13:12:55 +03:00
07bc7ef1e0 mangalib 2026-05-02 22:31:33 +03:00
a7eaa22646 mangalib 2026-05-02 21:59:59 +03:00
419614d295 upd 2026-05-02 20:15:36 +03:00
fcd1dfb74c upd 2026-05-02 20:03:21 +03:00
11 changed files with 847 additions and 297 deletions

View File

@@ -19,4 +19,4 @@ VOLUME ["/app/output", "/app/state"]
# По умолчанию запускаем веб-сервер
ENTRYPOINT []
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]

352
PLAN_MULTI_SOURCE.md Normal file
View 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 + предупреждение + ручной выбор для неизвестных доменов
- Диалог смены источника с предупреждением о перепривязке домена
- Вкладка «Настройки → Источники»

View File

@@ -8,9 +8,6 @@ services:
- ./state:/app/state
environment:
- PYTHONUNBUFFERED=1
# Заставляем glibc возвращать освобождённую память ОС (уменьшает RSS в простое)
- MALLOC_MMAP_THRESHOLD_=65536
- MALLOC_TRIM_THRESHOLD_=65536
# Расписание авто-проверки новых глав (cron-синтаксис).
# Примеры: "0 */6 * * *" — каждые 6 ч | "0 3 * * *" — каждый день в 03:00
# Оставьте пустым чтобы отключить планировщик.

View File

@@ -222,19 +222,6 @@
</div>
<div id="users-list" class="flex flex-col gap-2"></div>
</div>
<!-- Обновить все метаданные (только admin) -->
<div id="refresh-all-section" class="hidden px-5 py-4 border-t border-gray-800">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-1">Обновить все метаданные</h3>
<p class="text-xs text-gray-500 mb-3">Запускает браузер для каждой скачанной манги: обновляет обложку, синопсис, жанры и метаданные в файлах CBZ/PDF/EPUB.</p>
<div class="flex items-center gap-3 flex-wrap">
<button id="refresh-all-btn" onclick="refreshAllMeta()"
class="text-xs px-4 py-2 rounded-lg font-semibold text-white"
style="background:#312e81;border:1px solid #4338ca;color:#a78bfa">
🔄 Обновить все метаданные
</button>
<div id="refresh-all-status" class="text-xs text-gray-400 hidden"></div>
</div>
</div>
<!-- Смена своего пароля -->
<div id="chpwd-section" class="px-5 py-4 border-t border-gray-800">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Сменить пароль</h3>
@@ -406,6 +393,7 @@ const state = {
currentUser: null, // {id, username, role}
authWarnings: {}, // source_slug → {source_slug, source_name}
metaUpdating: new Set(), // urls where meta refresh is in progress
validating: {}, // url → {checked, total} for in-progress validations
};
// ── Auth ─────────────────────────────────────
@@ -716,16 +704,29 @@ function handleEvent(msg) {
_updateMetaBtn(msg.url, msg.failed === -1 ? 'error' : 'done');
break;
case 'refresh_all_started':
_handleRefreshAllStarted(msg);
case 'validate_started':
state.validating[msg.url] = {checked: 0, total: 0};
_updateValidateBtn(msg.url);
break;
case 'refresh_all_progress':
_handleRefreshAllProgress(msg);
case 'validate_progress':
if(state.validating[msg.url]) {
state.validating[msg.url].checked = msg.checked;
state.validating[msg.url].total = msg.total;
}
_updateValidateBtn(msg.url);
break;
case 'refresh_all_done':
_handleRefreshAllDone(msg);
case 'validate_done': {
delete state.validating[msg.url];
const result = msg.total_to_redownload > 0 || msg.new_chapters > 0 ? 'issues' : 'ok';
_updateValidateBtn(msg.url, result, msg);
break;
}
case 'validate_error':
delete state.validating[msg.url];
_updateValidateBtn(msg.url, 'error');
break;
case 'manga_meta_updated':
@@ -837,7 +838,7 @@ function switchTab(tab) {
document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas');
if(tab === 'history') loadHistory();
if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); }
if(tab === 'settings') { loadSources(); showUsersSection(); showRefreshAllSection(); }
if(tab === 'settings') { loadSources(); showUsersSection(); }
}
function updateNewsBadge() {
@@ -1157,11 +1158,6 @@ function showUsersSection() {
}
}
function showRefreshAllSection() {
const el = document.getElementById('refresh-all-section');
if(el) el.classList.toggle('hidden', !isAdmin());
}
async function loadUsers() {
if(!isAdmin()) return;
try {
@@ -1684,7 +1680,7 @@ function _updateMetaBtn(url, result) {
btn.style.color = '#4ade80';
btn.style.borderColor = '#166534';
setTimeout(() => {
btn.innerHTML = '🏷 Обновить метаданные';
btn.innerHTML = '🏷 Обновить метатеги';
btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81';
}, 2500);
@@ -1694,12 +1690,12 @@ function _updateMetaBtn(url, result) {
btn.style.color = '#f87171';
btn.style.borderColor = '#7f1d1d';
setTimeout(() => {
btn.innerHTML = '🏷 Обновить метаданные';
btn.innerHTML = '🏷 Обновить метатеги';
btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81';
}, 3000);
} else {
btn.innerHTML = '🏷 Обновить метаданные';
btn.innerHTML = '🏷 Обновить метатеги';
btn.disabled = false;
btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81';
@@ -1721,43 +1717,60 @@ async function refreshMetaModal(url) {
// Спиннер появится через WS meta_refresh_started, исчезнет через meta_refreshed
}
async function refreshAllMeta() {
const btn = document.getElementById('refresh-all-btn');
const status = document.getElementById('refresh-all-status');
const r = await fetch('/api/mangas/refresh_all_meta', {method:'POST'});
function _updateValidateBtn(url, result, data) {
const modal = document.getElementById('modal');
if(!modal || modal.classList.contains('hidden') || modal.dataset.currentUrl !== url) return;
const btn = document.getElementById('modal-validate-btn');
if(!btn) return;
const v = state.validating[url];
if(v !== undefined) {
const prog = v.total > 0 ? ` ${v.checked}/${v.total}` : '...';
btn.innerHTML = `<span class="meta-spinner"></span> Проверка${prog}`;
btn.disabled = true;
btn.style.color = '#94a3b8';
btn.style.borderColor = '#334155';
} else if(result === 'ok') {
btn.innerHTML = '✅ Всё в порядке';
btn.disabled = false;
btn.style.color = '#4ade80';
btn.style.borderColor = '#166534';
setTimeout(() => { btn.innerHTML = '🔍 Проверить целостность'; btn.style.color='#67e8f9'; btn.style.borderColor='#164e63'; btn.disabled=false; }, 3000);
} else if(result === 'issues') {
const n = data ? (data.total_to_redownload + data.new_chapters) : '?';
btn.innerHTML = `⚡ Найдено проблем: ${n} — поставлено в очередь`;
btn.disabled = true;
btn.style.color = '#fbbf24';
btn.style.borderColor = '#78350f';
setTimeout(() => { btn.innerHTML = '🔍 Проверить целостность'; btn.style.color='#67e8f9'; btn.style.borderColor='#164e63'; btn.disabled=false; }, 5000);
} else if(result === 'error') {
btn.innerHTML = '❌ Ошибка валидации';
btn.disabled = false;
btn.style.color = '#f87171';
btn.style.borderColor = '#7f1d1d';
setTimeout(() => { btn.innerHTML = '🔍 Проверить целостность'; btn.style.color='#67e8f9'; btn.style.borderColor='#164e63'; btn.disabled=false; }, 3000);
} else {
btn.innerHTML = '🔍 Проверить целостность';
btn.disabled = false;
btn.style.color = '#67e8f9';
btn.style.borderColor = '#164e63';
}
}
async function validateManga(url) {
const btn = document.getElementById('modal-validate-btn');
if(btn) {
btn.innerHTML = '<span class="meta-spinner"></span> Запуск...';
btn.disabled = true;
}
const r = await fetch('/api/mangas/validate?url='+encodeURIComponent(url), {method:'POST'});
if(!r.ok) {
const err = await r.json().catch(() => ({}));
if(status) { status.textContent = err.detail || 'Ошибка запуска'; status.classList.remove('hidden'); status.style.color = '#f87171'; }
return;
if(btn) {
btn.innerHTML = '❌ ' + (err.detail || 'Ошибка');
btn.style.color = '#f87171';
btn.style.borderColor = '#7f1d1d';
setTimeout(() => { btn.innerHTML = '🔍 Проверить целостность'; btn.style.color='#67e8f9'; btn.style.borderColor='#164e63'; btn.disabled=false; }, 3000);
}
if(btn) { btn.disabled = true; btn.textContent = '⏳ Запускаем...'; }
if(status) { status.textContent = 'Инициализация...'; status.classList.remove('hidden'); status.style.color = '#94a3b8'; }
}
function _handleRefreshAllStarted(data) {
const btn = document.getElementById('refresh-all-btn');
const status = document.getElementById('refresh-all-status');
if(btn) { btn.disabled = true; btn.textContent = '⏳ Обновляем...'; }
if(status) { status.textContent = `0 / ${data.total}`; status.classList.remove('hidden'); status.style.color = '#94a3b8'; }
}
function _handleRefreshAllProgress(data) {
const status = document.getElementById('refresh-all-status');
if(status) {
const title = data.title ? `${data.title}` : '';
status.textContent = `${data.done + 1} / ${data.total}${title}`;
status.style.color = '#94a3b8';
}
}
function _handleRefreshAllDone(data) {
const btn = document.getElementById('refresh-all-btn');
const status = document.getElementById('refresh-all-status');
if(btn) { btn.disabled = false; btn.textContent = '🔄 Обновить все метаданные'; }
if(status) {
status.textContent = `Готово: ${data.total} манг, обновлено файлов: ${data.total_updated}`;
status.style.color = '#4ade80';
status.classList.remove('hidden');
}
}
@@ -2303,10 +2316,19 @@ function renderModalBody(data) {
📁 Переименовать папку
</button>` : ''}
${data.status === 'done' && canManage(data) ? `
<button id="modal-validate-btn" onclick="validateManga('${escHtml(data.url)}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
style="background:#0c1a2e;color:#67e8f9;border:1px solid #164e63"
${state.validating[data.url] !== undefined ? 'disabled' : ''}>
${state.validating[data.url] !== undefined
? `<span class="meta-spinner"></span> Проверка${state.validating[data.url].total > 0 ? ' '+state.validating[data.url].checked+'/'+state.validating[data.url].total : '...'}`
: '🔍 Проверить целостность'}
</button>` : ''}
${data.status === 'done' && canManage(data) ? `
<button id="modal-refresh-meta-btn" onclick="refreshMetaModal('${escHtml(data.url)}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
style="background:#1e1b4b;color:#a78bfa;border:1px solid #312e81">
🏷 Обновить метаданные
🏷 Обновить метатеги
</button>` : ''}
${data.status !== 'downloading' && data.status !== 'queued' && isAdmin() ? `
<button onclick="forceRedownloadModal('${escHtml(data.url)}')"

View File

@@ -6,7 +6,7 @@ ebooklib==0.18
tqdm==4.66.4
loguru==0.7.2
fastapi==0.111.0
uvicorn==0.29.0
uvicorn[standard]==0.29.0
websockets==12.0
pypdf==4.2.0
croniter==3.0.3

View File

@@ -3,8 +3,6 @@ FastAPI веб-сервер: REST API + WebSocket для мониторинга
Многопользовательская система с ролями admin / user.
"""
import asyncio
import ctypes
import gc
import json
import os
import shutil
@@ -18,7 +16,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from loguru import logger
from .state import StateDB
from .worker import download_manga, check_for_updates, refresh_manga_metadata
from .worker import download_manga, check_for_updates, validate_manga
from .browser import BrowserManager
from .exporter import patch_meta, MangaMeta
from .sources import registry, get_source_for_url, extract_domain
@@ -73,7 +71,6 @@ ws_manager = ConnectionManager()
# ── Очередь загрузки ─────────────────────────
download_queue: asyncio.Queue = asyncio.Queue()
active_tasks: dict = {}
_refresh_all_running: bool = False
async def _broadcast_queue_positions():
queue_list = list(download_queue._queue)
positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)}
@@ -157,7 +154,6 @@ async def startup_event():
_db.close()
asyncio.create_task(queue_worker())
asyncio.create_task(update_scheduler())
asyncio.create_task(memory_trimmer())
db = StateDB()
try:
for manga in db.get_all_mangas():
@@ -192,17 +188,6 @@ def _parse_schedule() -> Optional[str]:
except ValueError:
logger.error("UPDATE_INTERVAL_HOURS='{}' — не число", hours_raw)
return None
async def memory_trimmer():
"""Периодически принудительно возвращает неиспользуемую память ОС."""
while True:
await asyncio.sleep(600) # каждые 10 минут
gc.collect()
try:
ctypes.CDLL("libc.so.6").malloc_trim(0)
except Exception:
pass
async def update_scheduler():
cron_expr = _parse_schedule()
if not cron_expr:
@@ -694,75 +679,106 @@ async def refresh_meta(url: str, current_user: dict = Depends(get_current_user))
db.close()
asyncio.create_task(_do_refresh_meta(url))
return {"ok": True}
def _patch_meta_sync(manga: dict, chapters: list, chapters_total: int, pub_status: str) -> tuple[int, int]:
updated = failed = 0
url = manga["url"]
summary = manga.get("description") or ""
tags_raw = manga.get("tags") or ""
try:
tags_str = ", ".join(json.loads(tags_raw)) if tags_raw else ""
except Exception:
tags_str = ""
for ch in chapters:
for fmt_col in ("output_cbz", "output_pdf", "output_epub"):
fpath = ch.get(fmt_col)
if not fpath:
continue
p = Path(fpath)
if not p.exists():
continue
meta = MangaMeta(
series=manga.get("title_ru") or manga.get("title") or "",
series_full=manga.get("title_full") or "",
chapter_title=ch.get("title") or "",
number=float(ch.get("number") or 0),
volume=int(ch.get("volume") or 0),
chapters_total=chapters_total,
pub_status=pub_status,
source_url=url,
summary=summary,
tags=tags_str,
)
if patch_meta(p, meta):
updated += 1
else:
failed += 1
return updated, failed
def _refresh_cover_sync(manga: dict, manga_dir: Path) -> None:
"""Скачивает или обновляет обложку через urllib (синхронно, для asyncio.to_thread)."""
import urllib.request as _urllib_req
import re as _re
cover_url = manga.get("cover_url") or ""
if not cover_url:
return
# Определяем Referer по URL обложки (MangaLib CDN — cdnlibs / mangalib)
if any(pat in cover_url for pat in ("mangalib", "cdnlibs", "imglib")):
referer = "https://mangalib.me/"
else:
from urllib.parse import urlparse as _up
parsed = _up(manga.get("url") or "")
referer = f"{parsed.scheme}://{parsed.netloc}/" if parsed.netloc else "https://readmanga.ru/"
try:
req = _urllib_req.Request(cover_url, headers={
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124.0.0.0",
"Referer": referer,
"Accept": "image/png,image/jpeg,image/webp,image/*,*/*",
})
with _urllib_req.urlopen(req, timeout=30) as resp:
body = resp.read()
if len(body) < 500:
logger.warning("refresh_cover: слишком малый ответ ({} байт)", len(body))
return
m = _re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", cover_url, _re.IGNORECASE)
ext = ("." + (m.group(1).lower() if m else "jpg")).replace(".jpeg", ".jpg")
cover_path = manga_dir / f"cover{ext}"
cover_path.write_bytes(body)
logger.info("Обложка обновлена: {} ({} байт)", cover_path.name, len(body))
except Exception as e:
logger.warning("refresh_cover error {}: {}", cover_url, e)
async def _do_refresh_meta(url: str):
db = StateDB()
try:
manga = db.get_manga(url)
if not manga:
return
chapters = db.get_all_chapters(url)
chapters_total = len(chapters)
pub_status = manga.get("pub_status", "unknown") or "unknown"
finally:
db.close()
try:
await ws_manager.broadcast({"type": "meta_refresh_started", "url": url})
updated, failed = await refresh_manga_metadata(url)
updated, failed = await asyncio.to_thread(_patch_meta_sync, manga, chapters, chapters_total, pub_status)
logger.info("refresh_meta {}: обновлено {}, ошибок {}", url, updated, failed)
# Обновляем обложку если у манги формат cbz
manga_fmt = manga.get("format", "cbz") or "cbz"
if manga_fmt in ("cbz", "all") and manga.get("cover_url"):
manga_dir = _manga_folder(manga)
if manga_dir.exists():
await asyncio.to_thread(_refresh_cover_sync, manga, manga_dir)
await ws_manager.broadcast({"type": "meta_refreshed", "url": url,
"updated": updated, "failed": failed})
except Exception as e:
logger.error("_do_refresh_meta {}: {}", url, e)
await ws_manager.broadcast({"type": "meta_refreshed", "url": url, "updated": 0, "failed": -1})
@app.post("/api/mangas/refresh_all_meta")
async def refresh_all_meta_endpoint(current_user: dict = Depends(require_admin)):
global _refresh_all_running
if _refresh_all_running:
raise HTTPException(status_code=409, detail="Обновление метаданных уже выполняется")
asyncio.create_task(_do_refresh_all_meta())
return {"ok": True}
@app.get("/api/mangas/refresh_all_meta/status")
async def refresh_all_meta_status(current_user: dict = Depends(require_admin)):
return {"running": _refresh_all_running}
async def _do_refresh_all_meta():
global _refresh_all_running
_refresh_all_running = True
db = StateDB()
try:
mangas = db.get_all_mangas()
finally:
db.close()
done_mangas = [m for m in mangas if m["status"] == "done"]
total = len(done_mangas)
await ws_manager.broadcast({"type": "refresh_all_started", "total": total})
logger.info("refresh_all_meta: начало, всего манг: {}", total)
total_updated = total_failed = 0
try:
for i, manga in enumerate(done_mangas):
url = manga["url"]
await ws_manager.broadcast({
"type": "refresh_all_progress",
"done": i,
"total": total,
"url": url,
"title": manga.get("title_ru") or manga.get("title") or url,
})
try:
updated, failed = await refresh_manga_metadata(url)
total_updated += updated
total_failed += failed
except Exception as e:
logger.error("refresh_all_meta {}: {}", url, e)
await ws_manager.broadcast({
"type": "refresh_all_done",
"total": total,
"total_updated": total_updated,
"total_failed": total_failed,
})
logger.info("refresh_all_meta: завершено, обновлено файлов: {}, ошибок: {}",
total_updated, total_failed)
finally:
_refresh_all_running = False
class UpdateMetaRequest(BaseModel):
url: str
title_ru: str
@@ -833,6 +849,44 @@ async def force_redownload(url: str, _: dict = Depends(require_admin)):
return {"ok": True}
finally:
db.close()
@app.post("/api/mangas/validate")
async def validate_manga_endpoint(url: str, current_user: dict = Depends(get_current_user)):
db = StateDB()
try:
manga = db.get_manga(url)
if not manga:
raise HTTPException(status_code=404, detail="Манга не найдена")
if manga["status"] != "done":
raise HTTPException(status_code=400, detail="Валидация доступна только для манг в статусе 'Готово'")
_check_manga_access(manga, current_user)
finally:
db.close()
asyncio.create_task(_do_validate(url))
return {"ok": True}
async def _do_validate(url: str):
db = StateDB()
try:
manga = db.get_manga(url)
fmt = manga["format"] if manga else "cbz"
finally:
db.close()
result = await validate_manga(url, on_event=ws_manager.broadcast)
if not result.get("ok"):
return
chapters_to_retry = result.get("chapters_to_redownload", [])
new_chapters = result.get("new_chapters", 0)
if not chapters_to_retry and not new_chapters:
return
db2 = StateDB()
try:
for chapter_url in chapters_to_retry:
db2.reset_chapter(chapter_url)
db2.update_manga_status(url, "queued")
finally:
db2.close()
await download_queue.put({"url": url, "fmt": fmt})
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": fmt})
await _broadcast_queue_positions()
@app.post("/api/mangas/stop")
async def stop_manga(url: str, current_user: dict = Depends(get_current_user)):
db = StateDB()

View File

@@ -1,15 +1,11 @@
"""
Браузерный слой: запуск Playwright Chromium с антидетект-настройками.
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Optional
from typing import Optional
from loguru import logger
if TYPE_CHECKING:
from playwright.async_api import Browser, BrowserContext, Page
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
# Реалистичный User-Agent Chrome 124 Linux
@@ -57,7 +53,6 @@ class BrowserManager:
self._browser: Optional[Browser] = None
async def start(self):
from playwright.async_api import async_playwright
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
headless=self.headless,

View File

@@ -1,13 +1,10 @@
"""
Базовые модели данных и Protocol-интерфейс для источников манги.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Protocol, runtime_checkable
from typing import Optional, Protocol, runtime_checkable
if TYPE_CHECKING:
from playwright.async_api import Page

View File

@@ -9,19 +9,15 @@
Получаем pages[] с полями: image (filename), url (relative path), slug (page index 1-based).
Изображения: {server}{page.url}, CDN = mixlib.me / imglib.info.
"""
from __future__ import annotations
import asyncio
import json as _json
import re
import time
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from typing import Optional
from urllib.parse import urlparse
from loguru import logger
if TYPE_CHECKING:
from playwright.async_api import Page
from .base import Chapter, MangaInfo, AuthRequiredError
@@ -386,6 +382,59 @@ class MangalibSource:
return [paths[i] for i in sorted(paths.keys())]
async def get_chapter_page_count(
self, page: Page, chapter_url: str, manga_url: Optional[str] = None
) -> int:
"""Открывает главу и возвращает количество страниц через API без скачивания изображений."""
pages_info: list = []
auth_err: list = []
lock = asyncio.Lock()
async def on_response(resp):
try:
if "api.cdnlibs.org" in resp.url and "/chapter?" in resp.url:
if resp.status in (401, 403):
auth_err.append(True)
return
body = await resp.body()
data = _json.loads(body)
async with lock:
if not pages_info:
pages_info.extend(data.get("data", {}).get("pages", []))
except Exception:
pass
if self.auth_token:
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
page.on("response", on_response)
referer = manga_url or _base_url(chapter_url)
ok = await _navigate(page, chapter_url, referer=referer)
if not ok:
mirror_url = _switch_to_mirror(chapter_url)
if mirror_url != chapter_url:
ok = await _navigate(
page, mirror_url,
referer=_switch_to_mirror(referer) if referer else referer,
)
if not ok:
page.remove_listener("response", on_response)
return 0
for _ in range(40):
async with lock:
if pages_info or auth_err:
break
await asyncio.sleep(0.5)
page.remove_listener("response", on_response)
if auth_err and not pages_info:
raise AuthRequiredError(self.slug)
return len(pages_info)
# ──────────────────────────────────────────────
# Вспомогательные функции (приватные)

View File

@@ -1,19 +1,15 @@
"""
Адаптер ReadManga: поддерживает readmanga.ru и все его клоны.
"""
from __future__ import annotations
import asyncio
import base64
import re
import time
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from typing import Optional
from urllib.parse import urlparse
from loguru import logger
if TYPE_CHECKING:
from playwright.async_api import Page
from .base import Chapter, MangaInfo
@@ -380,6 +376,19 @@ class ReadmangaSource:
return [paths[i] for i in sorted(paths.keys())]
async def get_chapter_page_count(
self, page: Page, chapter_url: str, manga_url: Optional[str] = None
) -> int:
"""Открывает главу и возвращает количество страниц без скачивания изображений."""
load_url = chapter_url + ("?mtr=1" if "?" not in chapter_url else "&mtr=1")
ok = await _navigate(page, load_url)
if not ok:
return 0
urls = await _extract_images_from_js(page)
if not urls:
urls = await _extract_images_from_dom(page)
return len(urls)
# ──────────────────────────────────────────────
# Вспомогательные функции (приватные)

View File

@@ -13,7 +13,7 @@ from .browser import BrowserManager
from .sources import registry, get_source_for_url, extract_domain
import json as _json
from .sources.base import Chapter, MangaInfo, AuthRequiredError
from .exporter import export, patch_meta, MangaMeta
from .exporter import export, MangaMeta
from .state import StateDB
from .utils import safe_name, safe_chapter_name
@@ -406,6 +406,207 @@ async def download_manga(
db.close()
async def validate_manga(
url: str,
output_dir: Path = OUTPUT_DIR,
on_event=None,
) -> dict:
"""
Проверяет целостность скачанной манги, сравнивая с сайтом.
- Получает актуальный список глав с сайта
- Добавляет новые главы в БД
- Для скачанных глав: проверяет наличие файлов и количество страниц
- Возвращает dict с chapters_to_redownload и статистикой
"""
async def emit(event: dict):
if on_event:
try:
await on_event(event)
except Exception as e:
logger.debug("on_event error: {}", e)
db = StateDB()
db_lock = asyncio.Lock()
async def db_call(fn, *args, **kwargs):
async with db_lock:
return fn(*args, **kwargs)
try:
await emit({"type": "validate_started", "url": url})
source = get_source_for_url(url, db)
if source is None:
manga_row = await db_call(db.get_manga, url)
if manga_row and manga_row.get("source_id"):
source = registry.get_by_db_id(manga_row["source_id"], db)
if source is None:
await emit({"type": "validate_error", "url": url,
"error": "Источник не определён. Выберите источник в настройках манги."})
return {"ok": False, "chapters_to_redownload": []}
if hasattr(source, "auth_token"):
_src_row = await db_call(db.get_source_by_slug, source.slug)
if _src_row:
_settings_raw = _src_row.get("settings") or "{}"
try:
_settings = _json.loads(_settings_raw) if isinstance(_settings_raw, str) else (_settings_raw or {})
except Exception:
_settings = {}
source.auth_token = _settings.get("auth_token") or None
manga_row = await db_call(db.get_manga, url)
fmt = (manga_row or {}).get("format", "cbz")
fmt_list = ["cbz", "pdf", "epub"] if fmt == "all" else [fmt]
async with BrowserManager(headless=True) as bm:
ctx, info_page = await bm.new_page()
try:
manga = await source.get_manga_info(info_page, url)
except Exception as e:
logger.error("validate: get_manga_info ошибка для {}: {}", url, e)
await emit({"type": "validate_error", "url": url, "error": str(e)})
return {"ok": False, "chapters_to_redownload": []}
finally:
await info_page.close()
if not manga:
await emit({"type": "validate_error", "url": url,
"error": "Не удалось получить информацию о манге с сайта"})
return {"ok": False, "chapters_to_redownload": []}
for ch in manga.chapters:
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
all_ch_rows = await db_call(db.get_all_chapters, url)
db_chapters = {c["chapter_url"]: c for c in all_ch_rows}
new_chapters = [
ch for ch in manga.chapters
if db_chapters.get(ch.url, {}).get("status") == "pending"
]
done_chapters = [
ch for ch in manga.chapters
if db_chapters.get(ch.url, {}).get("status") == "done"
]
to_redownload: set = set()
fast_issues = 0
for ch in done_chapters:
db_ch = db_chapters[ch.url]
if db_ch.get("pages_total", 0) > 0 and db_ch.get("pages_done", 0) < db_ch["pages_total"]:
to_redownload.add(ch.url)
fast_issues += 1
continue
for f in fmt_list:
fpath = db_ch.get(f"output_{f}")
if fpath and not Path(fpath).exists():
to_redownload.add(ch.url)
fast_issues += 1
break
chapters_for_deep = [
ch for ch in done_chapters if ch.url not in to_redownload
]
site_mismatched = 0
checked = 0
has_page_count = hasattr(source, "get_chapter_page_count")
if has_page_count and chapters_for_deep:
sem = asyncio.Semaphore(2)
count_lock = asyncio.Lock()
async def check_one(ch: Chapter) -> None:
nonlocal checked, site_mismatched
async with sem:
db_ch = db_chapters[ch.url]
ch_page = await ctx.new_page()
mismatch = False
site_count = 0
try:
site_count = await source.get_chapter_page_count(
ch_page, ch.url, url
)
except AuthRequiredError:
raise
except Exception as e:
logger.warning(
"validate page count Т{} Гл.{}: {}", ch.volume, ch.number, e
)
finally:
await ch_page.close()
pages_have = db_ch.get("pages_done", 0)
if site_count > 0 and site_count != pages_have:
mismatch = True
logger.info(
"validate: Т{} Гл.{} — сайт {} стр., у нас {} → повтор",
ch.volume, ch.number, site_count, pages_have,
)
async with count_lock:
checked += 1
if mismatch:
to_redownload.add(ch.url)
site_mismatched += 1
await emit({
"type": "validate_progress",
"url": url,
"checked": checked,
"total": len(chapters_for_deep),
"chapter_number": ch.number,
"volume": ch.volume,
"mismatch": mismatch,
"site_count": site_count,
})
results = await asyncio.gather(
*[check_one(ch) for ch in chapters_for_deep],
return_exceptions=True,
)
auth_slug = None
for res in results:
if isinstance(res, AuthRequiredError):
auth_slug = res.source_slug
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
logger.exception("validate gather exception: {}", res)
if auth_slug:
await emit({"type": "validate_error", "url": url,
"error": f"auth_required:{auth_slug}"})
return {"ok": False, "chapters_to_redownload": []}
to_redownload_list = list(to_redownload)
result = {
"ok": True,
"url": url,
"site_chapters": len(manga.chapters),
"new_chapters": len(new_chapters),
"fast_issues": fast_issues,
"site_mismatched": site_mismatched,
"total_to_redownload": len(to_redownload_list),
"chapters_to_redownload": to_redownload_list,
}
await emit({
"type": "validate_done",
**{k: v for k, v in result.items() if k != "chapters_to_redownload"},
})
return result
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("validate_manga {}: {}", url, e)
await emit({"type": "validate_error", "url": url, "error": str(e)})
return {"ok": False, "chapters_to_redownload": []}
finally:
db.close()
def _cover_ext_from_url(url: str) -> str:
import re as _re
m = _re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, _re.IGNORECASE)
@@ -555,129 +756,3 @@ async def check_for_updates(
finally:
db.close()
async def refresh_manga_metadata(
url: str,
output_dir: Path = OUTPUT_DIR,
on_event: Optional[Callable] = None,
) -> tuple[int, int]:
"""
Обновляет метаданные манги через браузер: скачивает обложку через Playwright,
обновляет ComicInfo.xml/PDF/EPUB с актуальными данными (включая жанры и синопсис).
Возвращает (updated, failed).
"""
async def emit(event: dict):
if on_event:
try:
await on_event(event)
except Exception:
pass
db = StateDB()
db_lock = asyncio.Lock()
async def db_call(fn, *args, **kwargs):
async with db_lock:
return fn(*args, **kwargs)
try:
source = get_source_for_url(url, db)
if source is None:
manga_row = await db_call(db.get_manga, url)
if manga_row and manga_row.get("source_id"):
source = registry.get_by_db_id(manga_row["source_id"], db)
if source is None:
logger.warning("refresh_manga_metadata: источник не найден для {}", url)
return 0, 0
# Inject auth token for sources that need it
if hasattr(source, "auth_token"):
_src_row = await db_call(db.get_source_by_slug, source.slug)
if _src_row:
_settings_raw = _src_row.get("settings") or "{}"
try:
_settings = _json.loads(_settings_raw) if isinstance(_settings_raw, str) else (_settings_raw or {})
except Exception:
_settings = {}
source.auth_token = _settings.get("auth_token") or None
async with BrowserManager(headless=True) as bm:
_, page = await bm.new_page()
try:
manga = await source.get_manga_info(page, url)
if not manga:
logger.warning("refresh_manga_metadata: get_manga_info вернул None для {}", url)
return 0, 0
# Сохраняем свежие данные в БД
await db_call(
db.update_manga_info, url,
title=manga.title_ru or manga.title,
chapters_total=len(manga.chapters),
title_ru=manga.title_ru,
title_full=manga.title_full,
pub_status=manga.pub_status,
description=manga.description,
tags=_json.dumps(manga.tags, ensure_ascii=False) if manga.tags else "",
cover_url=manga.cover_url,
)
# Скачиваем обложку через Playwright (правильные куки/заголовки)
manga_row = await db_call(db.get_manga, url)
manga_fmt = (manga_row or {}).get("format", "cbz") or "cbz"
folder_name = (
(manga_row.get("folder_name") if manga_row else None)
or safe_name(manga.title_ru or manga.title)
)
manga_dir = output_dir / folder_name
if manga.cover_url and manga_fmt in ("cbz", "all") and manga_dir.exists():
await _download_cover(manga.cover_url, manga_dir, url, page)
# Обновляем метаданные в файлах с актуальными данными из источника
chapters = await db_call(db.get_all_chapters, url)
chapters_total = len(chapters)
series = manga.title_ru or manga.title
series_full = manga.title_full or ""
pub_status = manga.pub_status or "unknown"
summary = manga.description or ""
tags_str = ", ".join(manga.tags) if manga.tags else ""
genre_str = ", ".join(manga.genres) if manga.genres else ""
def do_patch():
updated = failed = 0
for ch in chapters:
for fmt_col in ("output_cbz", "output_pdf", "output_epub"):
fpath = ch.get(fmt_col)
if not fpath:
continue
p = Path(fpath)
if not p.exists():
continue
meta = MangaMeta(
series=series,
series_full=series_full,
chapter_title=ch.get("title") or "",
number=float(ch.get("number") or 0),
volume=int(ch.get("volume") or 0),
chapters_total=chapters_total,
pub_status=pub_status,
source_url=url,
summary=summary,
tags=tags_str,
genre=genre_str,
)
if patch_meta(p, meta):
updated += 1
else:
failed += 1
return updated, failed
updated, failed = await asyncio.to_thread(do_patch)
logger.info("refresh_manga_metadata {}: обновлено {}, ошибок {}", url, updated, failed)
return updated, failed
finally:
await page.close()
finally:
db.close()