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

View File

@@ -222,19 +222,6 @@
</div> </div>
<div id="users-list" class="flex flex-col gap-2"></div> <div id="users-list" class="flex flex-col gap-2"></div>
</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"> <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> <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} currentUser: null, // {id, username, role}
authWarnings: {}, // source_slug → {source_slug, source_name} authWarnings: {}, // source_slug → {source_slug, source_name}
metaUpdating: new Set(), // urls where meta refresh is in progress metaUpdating: new Set(), // urls where meta refresh is in progress
validating: {}, // url → {checked, total} for in-progress validations
}; };
// ── Auth ───────────────────────────────────── // ── Auth ─────────────────────────────────────
@@ -716,16 +704,29 @@ function handleEvent(msg) {
_updateMetaBtn(msg.url, msg.failed === -1 ? 'error' : 'done'); _updateMetaBtn(msg.url, msg.failed === -1 ? 'error' : 'done');
break; break;
case 'refresh_all_started': case 'validate_started':
_handleRefreshAllStarted(msg); state.validating[msg.url] = {checked: 0, total: 0};
_updateValidateBtn(msg.url);
break; break;
case 'refresh_all_progress': case 'validate_progress':
_handleRefreshAllProgress(msg); if(state.validating[msg.url]) {
state.validating[msg.url].checked = msg.checked;
state.validating[msg.url].total = msg.total;
}
_updateValidateBtn(msg.url);
break; break;
case 'refresh_all_done': case 'validate_done': {
_handleRefreshAllDone(msg); 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; break;
case 'manga_meta_updated': case 'manga_meta_updated':
@@ -837,7 +838,7 @@ function switchTab(tab) {
document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas'); document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas');
if(tab === 'history') loadHistory(); if(tab === 'history') loadHistory();
if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); } if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); }
if(tab === 'settings') { loadSources(); showUsersSection(); showRefreshAllSection(); } if(tab === 'settings') { loadSources(); showUsersSection(); }
} }
function updateNewsBadge() { 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() { async function loadUsers() {
if(!isAdmin()) return; if(!isAdmin()) return;
try { try {
@@ -1684,7 +1680,7 @@ function _updateMetaBtn(url, result) {
btn.style.color = '#4ade80'; btn.style.color = '#4ade80';
btn.style.borderColor = '#166534'; btn.style.borderColor = '#166534';
setTimeout(() => { setTimeout(() => {
btn.innerHTML = '🏷 Обновить метаданные'; btn.innerHTML = '🏷 Обновить метатеги';
btn.style.color = '#a78bfa'; btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81'; btn.style.borderColor = '#312e81';
}, 2500); }, 2500);
@@ -1694,12 +1690,12 @@ function _updateMetaBtn(url, result) {
btn.style.color = '#f87171'; btn.style.color = '#f87171';
btn.style.borderColor = '#7f1d1d'; btn.style.borderColor = '#7f1d1d';
setTimeout(() => { setTimeout(() => {
btn.innerHTML = '🏷 Обновить метаданные'; btn.innerHTML = '🏷 Обновить метатеги';
btn.style.color = '#a78bfa'; btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81'; btn.style.borderColor = '#312e81';
}, 3000); }, 3000);
} else { } else {
btn.innerHTML = '🏷 Обновить метаданные'; btn.innerHTML = '🏷 Обновить метатеги';
btn.disabled = false; btn.disabled = false;
btn.style.color = '#a78bfa'; btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81'; btn.style.borderColor = '#312e81';
@@ -1721,43 +1717,60 @@ async function refreshMetaModal(url) {
// Спиннер появится через WS meta_refresh_started, исчезнет через meta_refreshed // Спиннер появится через WS meta_refresh_started, исчезнет через meta_refreshed
} }
async function refreshAllMeta() { function _updateValidateBtn(url, result, data) {
const btn = document.getElementById('refresh-all-btn'); const modal = document.getElementById('modal');
const status = document.getElementById('refresh-all-status'); if(!modal || modal.classList.contains('hidden') || modal.dataset.currentUrl !== url) return;
const r = await fetch('/api/mangas/refresh_all_meta', {method:'POST'}); 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) { if(!r.ok) {
const err = await r.json().catch(() => ({})); const err = await r.json().catch(() => ({}));
if(status) { status.textContent = err.detail || 'Ошибка запуска'; status.classList.remove('hidden'); status.style.color = '#f87171'; } if(btn) {
return; 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>` : ''} </button>` : ''}
${data.status === 'done' && canManage(data) ? ` ${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)}')" <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" 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"> style="background:#1e1b4b;color:#a78bfa;border:1px solid #312e81">
🏷 Обновить метаданные 🏷 Обновить метатеги
</button>` : ''} </button>` : ''}
${data.status !== 'downloading' && data.status !== 'queued' && isAdmin() ? ` ${data.status !== 'downloading' && data.status !== 'queued' && isAdmin() ? `
<button onclick="forceRedownloadModal('${escHtml(data.url)}')" <button onclick="forceRedownloadModal('${escHtml(data.url)}')"

View File

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

View File

@@ -3,8 +3,6 @@ FastAPI веб-сервер: REST API + WebSocket для мониторинга
Многопользовательская система с ролями admin / user. Многопользовательская система с ролями admin / user.
""" """
import asyncio import asyncio
import ctypes
import gc
import json import json
import os import os
import shutil import shutil
@@ -18,7 +16,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from loguru import logger from loguru import logger
from .state import StateDB 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 .browser import BrowserManager
from .exporter import patch_meta, MangaMeta from .exporter import patch_meta, MangaMeta
from .sources import registry, get_source_for_url, extract_domain from .sources import registry, get_source_for_url, extract_domain
@@ -73,7 +71,6 @@ ws_manager = ConnectionManager()
# ── Очередь загрузки ───────────────────────── # ── Очередь загрузки ─────────────────────────
download_queue: asyncio.Queue = asyncio.Queue() download_queue: asyncio.Queue = asyncio.Queue()
active_tasks: dict = {} active_tasks: dict = {}
_refresh_all_running: bool = False
async def _broadcast_queue_positions(): async def _broadcast_queue_positions():
queue_list = list(download_queue._queue) queue_list = list(download_queue._queue)
positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)} positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)}
@@ -157,7 +154,6 @@ async def startup_event():
_db.close() _db.close()
asyncio.create_task(queue_worker()) asyncio.create_task(queue_worker())
asyncio.create_task(update_scheduler()) asyncio.create_task(update_scheduler())
asyncio.create_task(memory_trimmer())
db = StateDB() db = StateDB()
try: try:
for manga in db.get_all_mangas(): for manga in db.get_all_mangas():
@@ -192,17 +188,6 @@ def _parse_schedule() -> Optional[str]:
except ValueError: except ValueError:
logger.error("UPDATE_INTERVAL_HOURS='{}' — не число", hours_raw) logger.error("UPDATE_INTERVAL_HOURS='{}' — не число", hours_raw)
return None 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(): async def update_scheduler():
cron_expr = _parse_schedule() cron_expr = _parse_schedule()
if not cron_expr: if not cron_expr:
@@ -694,75 +679,106 @@ async def refresh_meta(url: str, current_user: dict = Depends(get_current_user))
db.close() db.close()
asyncio.create_task(_do_refresh_meta(url)) asyncio.create_task(_do_refresh_meta(url))
return {"ok": True} 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): 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: try:
await ws_manager.broadcast({"type": "meta_refresh_started", "url": url}) 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, await ws_manager.broadcast({"type": "meta_refreshed", "url": url,
"updated": updated, "failed": failed}) "updated": updated, "failed": failed})
except Exception as e: except Exception as e:
logger.error("_do_refresh_meta {}: {}", url, e) logger.error("_do_refresh_meta {}: {}", url, e)
await ws_manager.broadcast({"type": "meta_refreshed", "url": url, "updated": 0, "failed": -1}) 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): class UpdateMetaRequest(BaseModel):
url: str url: str
title_ru: str title_ru: str
@@ -833,6 +849,44 @@ async def force_redownload(url: str, _: dict = Depends(require_admin)):
return {"ok": True} return {"ok": True}
finally: finally:
db.close() 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") @app.post("/api/mangas/stop")
async def stop_manga(url: str, current_user: dict = Depends(get_current_user)): async def stop_manga(url: str, current_user: dict = Depends(get_current_user)):
db = StateDB() db = StateDB()

View File

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

View File

@@ -1,14 +1,11 @@
""" """
Базовые модели данных и Protocol-интерфейс для источников манги. Базовые модели данных и Protocol-интерфейс для источников манги.
""" """
from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path 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
from playwright.async_api import Page
class AuthRequiredError(Exception): class AuthRequiredError(Exception):

View File

@@ -9,20 +9,16 @@
Получаем pages[] с полями: image (filename), url (relative path), slug (page index 1-based). Получаем pages[] с полями: image (filename), url (relative path), slug (page index 1-based).
Изображения: {server}{page.url}, CDN = mixlib.me / imglib.info. Изображения: {server}{page.url}, CDN = mixlib.me / imglib.info.
""" """
from __future__ import annotations
import asyncio import asyncio
import json as _json import json as _json
import re import re
import time import time
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Optional from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from loguru import logger from loguru import logger
from playwright.async_api import Page
if TYPE_CHECKING:
from playwright.async_api import Page
from .base import Chapter, MangaInfo, AuthRequiredError from .base import Chapter, MangaInfo, AuthRequiredError
@@ -386,6 +382,59 @@ class MangalibSource:
return [paths[i] for i in sorted(paths.keys())] 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,20 +1,16 @@
""" """
Адаптер ReadManga: поддерживает readmanga.ru и все его клоны. Адаптер ReadManga: поддерживает readmanga.ru и все его клоны.
""" """
from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import re import re
import time import time
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Optional from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from loguru import logger from loguru import logger
from playwright.async_api import Page
if TYPE_CHECKING:
from playwright.async_api import Page
from .base import Chapter, MangaInfo from .base import Chapter, MangaInfo
@@ -380,6 +376,19 @@ class ReadmangaSource:
return [paths[i] for i in sorted(paths.keys())] 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 from .sources import registry, get_source_for_url, extract_domain
import json as _json import json as _json
from .sources.base import Chapter, MangaInfo, AuthRequiredError from .sources.base import Chapter, MangaInfo, AuthRequiredError
from .exporter import export, patch_meta, MangaMeta from .exporter import export, MangaMeta
from .state import StateDB from .state import StateDB
from .utils import safe_name, safe_chapter_name from .utils import safe_name, safe_chapter_name
@@ -406,6 +406,207 @@ async def download_manga(
db.close() 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: def _cover_ext_from_url(url: str) -> str:
import re as _re import re as _re
m = _re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, _re.IGNORECASE) m = _re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, _re.IGNORECASE)
@@ -555,129 +756,3 @@ async def check_for_updates(
finally: finally:
db.close() 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()