diff --git a/CODE_REVIEW.md b/CODE_REVIEW.md deleted file mode 100644 index dd51709..0000000 --- a/CODE_REVIEW.md +++ /dev/null @@ -1,369 +0,0 @@ -# Code Review: находки и предложения - -> Статус: **только чтение** — ничего не изменено. Каждый пункт сопровождается предлагаемым исправлением. - ---- - -## 1. Дублирование кода — `_safe_name` / `_safe_chapter_name` - -**Файлы:** `src/api.py:251`, `src/worker.py:26–32`, `src/cli.py` (аналогичные функции) - -Одна и та же функция определена в трёх местах. При изменении логики нужно менять сразу в трёх файлах — риск расхождения. - -**Исправление:** вынести в `src/utils.py`, импортировать везде: - -```python -# src/utils.py -import re -from .sources.base import Chapter - -def safe_name(s: str) -> str: - return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80] - -def safe_chapter_name(ch: Chapter) -> str: - vol = f"v{ch.volume:02d}_" if ch.volume else "" - return f"{vol}ch{ch.number:06.1f}" -``` - ---- - -## 2. Прямой доступ к `db.conn` в API-эндпоинтах - -**Файлы:** `src/api.py` - -| Место | Строки | -|-------|--------| -| `_enrich_manga` | 269–278 | -| `retry_errors` | 680–688 | -| `force_redownload` | 819–823 | -| `delete_manga` | 882–885 | -| `rename_folder` | 801–803 | - -`api.py` напрямую исполняет SQL через `db.conn.execute(...)` вместо методов `StateDB`. Это означает, что логика разбросана между двумя слоями, и рефакторинг `StateDB` может незаметно сломать эндпоинты. - -Для `retry_errors` и `force_redownload` в `StateDB` уже есть готовый метод `reset_failed_chapters` — он просто не используется в API. - -**Исправление для `retry_errors`:** - -```python -# api.py — было: -now = db.conn.execute("SELECT datetime('now')").fetchone()[0] -db.conn.execute("UPDATE chapters SET status='pending' ...", (now, url)) -db.conn.commit() - -# стало: -db.reset_failed_chapters(url) -``` - -Для `delete_manga` и `rename_folder` добавить соответствующие методы в `StateDB`. - ---- - -## 3. `datetime.utcnow()` устарел - -**Файлы:** `src/api.py:369`, `src/state.py:628` - -`datetime.utcnow()` объявлен deprecated в Python 3.12. Используется в двух местах. - -**Исправление:** - -```python -# src/state.py -from datetime import datetime, timezone - -def _now() -> str: - return datetime.now(timezone.utc).replace(tzinfo=None).isoformat() - -# src/api.py — в login(): -expires_at = (datetime.now(timezone.utc) + timedelta(days=30)).replace(tzinfo=None).isoformat() -``` - ---- - -## 4. `check_for_updates` не использует `db_lock` - -**Файл:** `src/worker.py:343–400` - -`download_manga` тщательно оборачивает все обращения к БД в `db_lock` (`asyncio.Lock`). `check_for_updates` вызывает `db.update_manga_info`, `db.get_all_chapters`, `db.set_last_checked` напрямую без блокировки. При параллельном запуске нескольких авто-обновлений возможна конкуренция записей в одну и ту же строку SQLite. - -**Исправление:** добавить `db_lock` в `check_for_updates` по аналогии с `download_manga`, либо убедиться, что авто-обновления всегда запускаются последовательно (сейчас в `_run_auto_updates` они идут через `for manga in candidates` — это последовательно, но ручной `check_now` и авто-обновление могут пересечься). - ---- - -## 5. Хак `pages_done_count = [0]` - -**Файл:** `src/worker.py:196` - -```python -pages_done_count = [0] # мутабельный список вместо nonlocal - -async def on_page(page_idx: int, pages_total: int): - pages_done_count[0] += 1 -``` - -Это классический обходной путь для `nonlocal` в Python 2. В Python 3 достаточно `nonlocal`. - -**Исправление:** - -```python -pages_done = 0 - -async def on_page(page_idx: int, pages_total: int): - nonlocal pages_done - pages_done += 1 - await db_call(db.update_chapter_pages, ch.url, pages_total, pages_done) -``` - ---- - -## 6. Мёртвый код в `StateDB` - -**Файл:** `src/state.py:405–407` - -```python -def increment_manga_chapters_done(self, url: str): - # Оставлен для совместимости, но не используется в воркере - pass -``` - -Метод ничего не делает и нигде не вызывается. - -**Исправление:** удалить. - ---- - -## 7. Отложенный импорт `BrowserManager` в `_fetch_preview` - -**Файл:** `src/api.py:548` - -```python -async def _fetch_preview(url: str): - try: - from .browser import BrowserManager # импорт внутри функции -``` - -`BrowserManager` уже импортируется в `worker.py`. Импорт внутри функции — признак того, что его пытались скрыть из-за каких-то проблем при старте, но сейчас оснований для этого нет. Это замедляет первый вызов и затрудняет поиск зависимостей. - -**Исправление:** добавить `from .browser import BrowserManager` в топ-уровневые импорты `api.py`. - -Аналогично — `import shutil` внутри тел `rename_folder` (строка 789) и `delete_manga` (строка 879). Вынести в топ. - ---- - -## 8. O(n²) назначение позиций в очереди - -**Файл:** `src/api.py:486–491` - -```python -queue_list = list(download_queue._queue) -for i, job in enumerate(queue_list): - for r in result: # ← внутренний цикл по всем мангам - if r["url"] == job["url"]: - r["queue_position"] = i + 1 -``` - -При 100 мангах в очереди и 100 в БД — 10 000 итераций на каждый запрос `/api/mangas`. - -**Исправление:** - -```python -queue_positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)} -for r in result: - r["queue_position"] = queue_positions.get(r["url"]) -``` - ---- - -## 9. Утечка памяти в `_export_pdf_pillow` - -**Файл:** `src/exporter.py:131–135` - -```python -def _export_pdf_pillow(images: list[Path], out: Path): - from PIL import Image - pil_images = [Image.open(p).convert("RGB") for p in images] - if pil_images: - pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF") - # pil_images не закрываются — файловые дескрипторы висят до GC -``` - -**Исправление:** - -```python -def _export_pdf_pillow(images: list[Path], out: Path): - from PIL import Image - pil_images = [Image.open(p).convert("RGB") for p in images] - try: - if pil_images: - pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF") - finally: - for img in pil_images: - img.close() -``` - ---- - -## 10. Потенциальный SQL-инъекционный путь в `mark_done` - -**Файл:** `src/state.py:453–459` - -```python -def mark_done(self, chapter_url: str, fmt: str, output_path: str): - col = f"output_{fmt}" - self.conn.execute(f""" - UPDATE chapters SET status='done', {col}=?, updated_at=? - WHERE chapter_url=? - """, (output_path, _now(), chapter_url)) -``` - -Сейчас `fmt` всегда приходит из `["cbz", "pdf", "epub"]` в `worker.py`, поэтому эксплуатации нет. Но паттерн хрупкий: если завтра `fmt` придёт от пользователя через API, это станет инъекцией. - -**Исправление:** - -```python -_ALLOWED_FMTS = {"cbz", "pdf", "epub"} - -def mark_done(self, chapter_url: str, fmt: str, output_path: str): - if fmt not in _ALLOWED_FMTS: - raise ValueError(f"Unknown format: {fmt}") - col = f"output_{fmt}" - ... -``` - ---- - -## 11. Неиспользуемый метод `BrowserManager.navigate()` - -**Файл:** `src/browser.py` - -`BrowserManager` содержит метод `navigate()`, который нигде не вызывается — `readmanga.py` определяет собственный `_navigate`. Это мёртвый код. - -**Исправление:** удалить `navigate()` из `BrowserManager` или убрать `_navigate` из `readmanga.py` и использовать единый метод. - ---- - -## 12. `cli.py` использует устаревший шим вместо реестра источников - -**Файл:** `src/cli.py` - -```python -from .scraper import get_manga_info, get_chapter_images_and_download # shim -``` - -`scraper.py` — это обёртка-пустышка, делегирующая в `ReadmangaSource`. CLI не использует `SourceRegistry`, не поддерживает `MangaMeta` полноценно. При добавлении нового источника CLI не заработает автоматически. - -**Исправление:** переписать `cli.py` на использование `registry` и `get_source_for_url`, как это сделано в `worker.py`. - ---- - -## 13. Двойное чтение тела ответа в `saveRenameFolder` - -**Файл:** `frontend/index.html` - -```javascript -async function saveRenameFolder() { - const r = await fetch('/api/mangas/rename_folder', ...); - if (!r.ok) { - const err = await r.json(); // ← первое чтение - ... - } - const data = await r.json(); // ← второе чтение (тело уже прочитано!) -``` - -`Response.body` — это `ReadableStream`, читается один раз. Второй `r.json()` вернёт ошибку или пустой объект. - -**Исправление:** - -```javascript -const data = await r.json(); -if (!r.ok) { - showError(data.detail || 'Ошибка'); - return; -} -``` - ---- - -## 14. `escHtml()` не защищает от JS-инъекции в `onclick` - -**Файл:** `frontend/index.html` — различные места типа: - -```javascript -` +
+
+ +
Загрузка...
@@ -376,8 +388,11 @@ const state = { mangas: {}, // url → manga object chapters: {}, // manga_url → [chapter, ...] filter: 'all', + search: '', sources: [], // [{id, slug, display_name, domains}] currentUser: null, // {id, username, role} + authWarnings: {}, // source_slug → {source_slug, source_name} + metaUpdating: new Set(), // urls where meta refresh is in progress }; // ── Auth ───────────────────────────────────── @@ -523,6 +538,7 @@ function handleEvent(msg) { case 'snapshot': msg.mangas.forEach(m => { state.mangas[m.url] = m; }); renderList(); + renderAuthWarnings(); loadStats(); // Дополнительно запрашиваем свежие данные с сервера — на случай если // пока WS был отключён, статусы изменились и события были потеряны @@ -533,7 +549,7 @@ function handleEvent(msg) { if(!state.mangas[msg.url]) { const srcInfo = msg.source_id ? (state.sources.find(s => s.id === msg.source_id) || null) : null; state.mangas[msg.url] = { url: msg.url, title: msg.url, status: 'queued', format: msg.format, - chapters_total: 0, chapters_done: 0, size_human: '—', + chapters_total: 0, chapters_done: 0, size_human: '0.0 Б', added_by: msg.added_by || null, added_by_username: msg.added_by_username || null, source: srcInfo ? {id: srcInfo.id, slug: srcInfo.slug, display_name: srcInfo.display_name} : null }; @@ -677,8 +693,14 @@ function handleEvent(msg) { loadStats(); break; + case 'meta_refresh_started': + state.metaUpdating.add(msg.url); + _updateMetaBtn(msg.url); + break; + case 'meta_refreshed': - // Ничего не делаем визуально — файлы обновлены на диске + state.metaUpdating.delete(msg.url); + _updateMetaBtn(msg.url, msg.failed === -1 ? 'error' : 'done'); break; case 'manga_meta_updated': @@ -750,6 +772,29 @@ function handleEvent(msg) { updateMangaRow(msg.url); } break; + + case 'auth_required': + if(state.mangas[msg.url]) { + state.mangas[msg.url].status = 'stopped'; + state.mangas[msg.url].last_error = `auth_required:${msg.source_slug}`; + if(msg.finished_at) state.mangas[msg.url].finished_at = msg.finished_at; + } + state.authWarnings[msg.source_slug] = {source_slug: msg.source_slug, source_name: msg.source_slug}; + renderList(); + renderAuthWarnings(); + loadStats(); + break; + + case 'source_settings_updated': + loadSources().then(() => { + // Clear warnings for sources that now have a token + state.sources.forEach(s => { + if(s.has_token) delete state.authWarnings[s.slug]; + }); + // Refresh mangas to get cleared last_error values + _refreshMangaList().then(() => renderAuthWarnings()); + }); + break; } } @@ -1300,6 +1345,21 @@ function renderSources() { ` : ''}
+ ${s.supports_auth_token && isAdmin() ? ` +
+
Токен авторизации (Bearer JWT)
+ ${s.has_token ? `
+ ✓ Токен сохранён + +
` : ''} +
+ + +
+
+ ` : ''} `).join(''); } @@ -1358,6 +1418,78 @@ async function removeDomain(sourceId, domain) { } } +async function saveSourceToken(sourceId) { + const input = document.getElementById('token-input-' + sourceId); + if(!input) return; + const token = input.value.trim(); + try { + const r = await fetch(`/api/sources/${sourceId}/settings`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({settings: {auth_token: token}}), + }); + if(!r.ok) { + const err = await r.json(); + _showNotification('Ошибка: ' + (err.detail || 'неизвестная ошибка'), 'error'); + return; + } + input.value = ''; + _showNotification('Токен сохранён', 'success'); + await loadSources(); + } catch(e) { + _showNotification('Ошибка: ' + e.message, 'error'); + } +} + +async function clearSourceToken(sourceId) { + if(!confirm('Удалить токен авторизации?')) return; + try { + const r = await fetch(`/api/sources/${sourceId}/settings`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({settings: {auth_token: ''}}), + }); + if(r.ok) { + _showNotification('Токен удалён', 'success'); + await loadSources(); + } + } catch(e) {} +} + +function renderAuthWarnings() { + const container = document.getElementById('auth-warnings'); + if(!container) return; + // Collect unique source slugs with unresolved auth errors from current manga state + const slugs = {}; + Object.values(state.mangas).forEach(m => { + const err = m.last_error || ''; + if(err.startsWith('auth_required:')) { + const slug = err.slice('auth_required:'.length); + if(!slugs[slug]) { + const src = state.sources.find(s => s.slug === slug); + slugs[slug] = src ? src.display_name : slug; + } + } + }); + // Also include warnings from state.authWarnings (received via WS before manga list refresh) + Object.entries(state.authWarnings).forEach(([slug, info]) => { + if(!slugs[slug]) slugs[slug] = info.source_name || slug; + }); + const entries = Object.entries(slugs); + if(!entries.length) { + container.classList.add('hidden'); + container.innerHTML = ''; + return; + } + container.classList.remove('hidden'); + container.innerHTML = entries.map(([slug, name]) => ` +
+ + Токен авторизации для ${escHtml(name)} устарел или отсутствует. Обновите токен в . +
+ `).join(''); +} + // ── Switch Source Modal ─────────────────────── let _switchSourceUrl = null; @@ -1507,34 +1639,56 @@ async function confirmDelete() { loadStats(); } -async function refreshMeta(url) { - const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'}); - if(r.ok) { - const btn = document.querySelector(`[data-refresh-url="${CSS.escape(url)}"]`); - if(btn) { btn.textContent = '✓'; btn.disabled = true; setTimeout(() => { btn.textContent = '🏷'; btn.disabled = false; }, 2000); } +function _updateMetaBtn(url, result) { + const btn = document.getElementById('modal-refresh-meta-btn'); + if(!btn) return; + const inProgress = state.metaUpdating.has(url); + if(inProgress) { + btn.innerHTML = ' Обновляем...'; + btn.disabled = true; + btn.style.color = '#94a3b8'; + btn.style.borderColor = '#334155'; + } else if(result === 'done') { + btn.innerHTML = '✅ Готово'; + btn.disabled = false; + btn.style.color = '#4ade80'; + btn.style.borderColor = '#166534'; + setTimeout(() => { + btn.innerHTML = '🏷 Обновить метатеги'; + btn.style.color = '#a78bfa'; + btn.style.borderColor = '#312e81'; + }, 2500); + } else if(result === 'error') { + btn.innerHTML = '❌ Ошибка'; + btn.disabled = false; + btn.style.color = '#f87171'; + btn.style.borderColor = '#7f1d1d'; + setTimeout(() => { + btn.innerHTML = '🏷 Обновить метатеги'; + btn.style.color = '#a78bfa'; + btn.style.borderColor = '#312e81'; + }, 3000); + } else { + btn.innerHTML = '🏷 Обновить метатеги'; + btn.disabled = false; + btn.style.color = '#a78bfa'; + btn.style.borderColor = '#312e81'; } } -async function refreshMetaModal(url) { - const btn = document.getElementById('modal-refresh-meta-btn'); - if(btn) { btn.textContent = '⏳ Обновляем...'; btn.disabled = true; } +async function refreshMeta(url) { const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'}); - if(btn) { - if(r.ok) { - btn.textContent = '✅ Метатеги обновлены'; - btn.style.color = '#4ade80'; - btn.style.borderColor = '#166534'; - setTimeout(() => { - btn.textContent = '🏷 Обновить метатеги'; - btn.disabled = false; - btn.style.color = '#a78bfa'; - btn.style.borderColor = '#312e81'; - }, 2500); - } else { - btn.textContent = '❌ Ошибка'; - btn.disabled = false; - } + if(!r.ok) return; + // state будет обновлён через WS meta_refresh_started +} + +async function refreshMetaModal(url) { + const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'}); + if(!r.ok) { + const btn = document.getElementById('modal-refresh-meta-btn'); + if(btn) { btn.innerHTML = '❌ Ошибка'; } } + // Спиннер появится через WS meta_refresh_started, исчезнет через meta_refreshed } async function forceRedownload(url, closeModalAfter = false) { @@ -1827,9 +1981,27 @@ function _rowAuto(m) { `; } +let _searchTimer = null; +function onMangaSearch(val) { + clearTimeout(_searchTimer); + _searchTimer = setTimeout(() => { state.search = val.trim().toLowerCase(); renderList(); }, 120); +} + function _sortedMangas() { let mangas = Object.values(state.mangas); - if(state.filter !== 'all') mangas = mangas.filter(m => m.status === state.filter); + if(state.filter === 'ongoing') { + mangas = mangas.filter(m => m.pub_status === 'ongoing'); + } else if(state.filter !== 'all') { + mangas = mangas.filter(m => m.status === state.filter); + } + if(state.search) { + const q = state.search; + mangas = mangas.filter(m => + (m.title || '').toLowerCase().includes(q) || + (m.title_ru || '').toLowerCase().includes(q) || + (m.title_full || '').toLowerCase().includes(q) + ); + } const order = {downloading: 0, queued: 1, stopped: 2, failed: 3, done: 4}; mangas.sort((a, b) => { const oa = order[a.status] ?? 2, ob = order[b.status] ?? 2; @@ -2310,6 +2482,7 @@ async function _refreshMangaList() { const mangas = await r.json(); mangas.forEach(m => { state.mangas[m.url] = m; }); renderList(); + renderAuthWarnings(); } catch(e) {} } diff --git a/src/api.py b/src/api.py index 7db2b33..864c804 100644 --- a/src/api.py +++ b/src/api.py @@ -3,6 +3,7 @@ FastAPI веб-сервер: REST API + WebSocket для мониторинга Многопользовательская система с ролями admin / user. """ import asyncio +import json import os import shutil from datetime import datetime, timedelta, timezone @@ -265,7 +266,8 @@ def _format_size(bytes_val: int) -> str: bytes_val /= 1024 return f"{bytes_val:.1f} ТБ" def _enrich_manga(m: dict, db: StateDB) -> dict: - size_bytes = _dir_size(_manga_folder(m)) + folder = _manga_folder(m) + size_bytes = _dir_size(folder) if (m.get("folder_name") or m.get("title")) else 0 stats = db.get_chapter_stats(m["url"]) source_info = None if m.get("source_id"): @@ -677,6 +679,78 @@ 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: @@ -686,36 +760,25 @@ async def _do_refresh_meta(url: str): chapters = db.get_all_chapters(url) chapters_total = len(chapters) pub_status = manga.get("pub_status", "unknown") or "unknown" - updated = failed = 0 - for ch in chapters: - for fmt_col, ext in (("output_cbz", ".cbz"), ("output_pdf", ".pdf"), ("output_epub", ".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, - ) - if patch_meta(p, meta): - updated += 1 - else: - failed += 1 + finally: + db.close() + try: + await ws_manager.broadcast({"type": "meta_refresh_started", "url": 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) - finally: - db.close() + await ws_manager.broadcast({"type": "meta_refreshed", "url": url, "updated": 0, "failed": -1}) class UpdateMetaRequest(BaseModel): url: str title_ru: str @@ -846,11 +909,20 @@ class DomainAdd(BaseModel): class SwitchSourceRequest(BaseModel): url: str source_id: int +class UpdateSourceSettingsRequest(BaseModel): + settings: dict @app.get("/api/sources") async def list_sources(_: dict = Depends(get_current_user)): db = StateDB() try: - return db.get_all_sources() + sources = db.get_all_sources() + for s in sources: + src_obj = registry.get_by_slug(s["slug"]) + s["supports_auth_token"] = bool(src_obj and getattr(src_obj, "supports_auth_token", False)) + settings = s.get("settings") or {} + s["has_token"] = bool(settings.get("auth_token")) + settings.pop("auth_token", None) # never send raw token to frontend + return sources finally: db.close() @app.get("/api/resolve-source") @@ -902,6 +974,33 @@ async def remove_domain(source_id: int, domain: str, _: dict = Depends(require_a return {"ok": True} finally: db.close() +@app.patch("/api/sources/{source_id}/settings") +async def update_source_settings(source_id: int, body: UpdateSourceSettingsRequest, + _: dict = Depends(require_admin)): + db = StateDB() + try: + source = db.get_source_by_id(source_id) + if not source: + raise HTTPException(status_code=404, detail="Источник не найден") + existing_raw = source.get("settings") or "{}" + try: + existing = json.loads(existing_raw) if isinstance(existing_raw, str) else (existing_raw or {}) + except Exception: + existing = {} + existing.update(body.settings) + # Remove empty/null auth_token to keep settings clean + if "auth_token" in existing and not existing["auth_token"]: + del existing["auth_token"] + db.update_source_settings(source_id, existing) + # If auth_token was saved, clear auth errors on mangas from this source + if body.settings.get("auth_token"): + for m in db.get_mangas_by_source(source_id): + if (m.get("last_error") or "").startswith("auth_required:"): + db.set_manga_last_error(m["url"], None) + await ws_manager.broadcast({"type": "source_settings_updated", "source_id": source_id}) + return {"ok": True} + finally: + db.close() @app.post("/api/mangas/switch-source") async def switch_manga_source(body: SwitchSourceRequest, _: dict = Depends(require_admin)): db = StateDB() diff --git a/src/cli.py b/src/cli.py index 6c806cb..40d7830 100644 --- a/src/cli.py +++ b/src/cli.py @@ -234,9 +234,9 @@ async def _analyze(url: str): paths = await source.get_chapter_images_and_download( page, first.url, dest_dir=Path(tmp), manga_url=url ) - click.echo(f" Скачано изображений: {len(paths)}") - for p in paths[:3]: - click.echo(f" {p.name} ({p.stat().st_size} байт)") + click.echo(f" Скачано изображений: {len(paths)}") + for p in paths[:3]: + click.echo(f" {p.name} ({p.stat().st_size} байт)") db.close() diff --git a/src/exporter.py b/src/exporter.py index d4614f8..f17be23 100644 --- a/src/exporter.py +++ b/src/exporter.py @@ -26,6 +26,7 @@ class MangaMeta: language: str = "ru" summary: str = "" # Описание/синопсис серии genre: str = "" # Жанры через запятую (для ComicInfo Genre) + tags: str = "" # Теги через запятую (для ComicInfo Tags) series_group: str = "" # Группа/коллекция (для ComicInfo SeriesGroup) @@ -89,6 +90,7 @@ def _make_comic_info(meta: MangaMeta) -> str: add("Count", meta.chapters_total) add("Genre", meta.genre) + add("Tags", meta.tags) add("LanguageISO", meta.language) # Manga = YesAndRightToLeft — стандартная японская манга diff --git a/src/sources/__init__.py b/src/sources/__init__.py index 95666f0..9765c5e 100644 --- a/src/sources/__init__.py +++ b/src/sources/__init__.py @@ -10,11 +10,13 @@ from typing import Optional from .base import MangaSourceProtocol from .readmanga import ReadmangaSource +from .mangalib import MangalibSource # ── Регистрация источников ───────────────────── # Добавьте новые источники сюда: SOURCES: list = [ ReadmangaSource(), + MangalibSource(), ] # Быстрый поиск по slug diff --git a/src/sources/base.py b/src/sources/base.py index 9438340..ccc5bdb 100644 --- a/src/sources/base.py +++ b/src/sources/base.py @@ -8,6 +8,13 @@ from typing import Optional, Protocol, runtime_checkable from playwright.async_api import Page +class AuthRequiredError(Exception): + """Источник требует авторизации — токен не задан или просрочен.""" + def __init__(self, source_slug: str): + self.source_slug = source_slug + super().__init__(f"Auth required for source: {source_slug}") + + # ────────────────────────────────────────────── # Модели данных (общие для всех источников) # ────────────────────────────────────────────── @@ -30,6 +37,8 @@ class MangaInfo: title_full: str = "" description: str = "" genres: list[str] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + cover_url: str = "" # ────────────────────────────────────────────── diff --git a/src/sources/mangalib.py b/src/sources/mangalib.py new file mode 100644 index 0000000..0f43362 --- /dev/null +++ b/src/sources/mangalib.py @@ -0,0 +1,792 @@ +""" +Адаптер MangaLib: поддерживает mangalib.me и его зеркала. + +Принцип работы: +- Список глав: перехватываем ответ api.cdnlibs.org/api/manga/{slug}/chapters + Возвращает все главы сразу (не требует пагинации). + URL главы: {origin}/ru/{manga_slug}/read/v{vol}/c{num} +- Страница главы: перехватываем ответ api.cdnlibs.org/api/manga/{slug}/chapter?... + Получаем pages[] с полями: image (filename), url (relative path), slug (page index 1-based). + Изображения: {server}{page.url}, CDN = mixlib.me / imglib.info. +""" +import asyncio +import json as _json +import re +import time +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +from loguru import logger +from playwright.async_api import Page + +from .base import Chapter, MangaInfo, AuthRequiredError + + +class MangalibSource: + slug = "mangalib" + display_name = "MangaLib" + supports_auth_token = True + + # CDN-домены для изображений глав (актуальные) + cdn_patterns = ["mixlib.me", "imglib.info", "imglib", "imgslib"] + + # Токен авторизации — устанавливается воркером из настроек источника в БД + auth_token: Optional[str] = None + + # ────────────────────────────────────────────── + # Страница манги — список глав + # ────────────────────────────────────────────── + + async def get_manga_info(self, page: Page, url: str) -> Optional[MangaInfo]: + """Открывает страницу манги и возвращает список всех глав.""" + logger.info("Загружаем страницу манги MangaLib: {}", url) + + chapters_url = _ensure_chapters_section(url) + base_manga_url = url.split("?")[0].rstrip("/") + + # Слушаем API-ответы до навигации + chapters_api_data: list = [] + manga_api_data: dict = {} + chapters_auth_error: list = [] + lock = asyncio.Lock() + + async def on_response(resp): + resp_url = resp.url + if "api.cdnlibs.org" not in resp_url: + return + try: + # api.cdnlibs.org/api/manga/{slug}/chapters (без query-параметров) + if re.search(r"/chapters$", resp_url): + if resp.status in (401, 403): + chapters_auth_error.append(True) + return + body = await resp.body() + data = _json.loads(body) + raw = data.get("data", []) + if isinstance(raw, list) and raw: + async with lock: + if not chapters_api_data: + chapters_api_data.extend(raw) + logger.debug("Chapters API: {} глав получено", len(raw)) + # api.cdnlibs.org/api/manga/{slug}?fields[]=... + elif re.search(r"/manga/[^/]+$", resp_url.split("?")[0]) and "fields" in resp_url: + body = await resp.body() + data = _json.loads(body) + raw = data.get("data", {}) + if isinstance(raw, dict) and raw: + async with lock: + if not manga_api_data: + manga_api_data.update(raw) + except Exception as e: + logger.debug("API parse error: {}", e) + + if self.auth_token: + await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"}) + page.on("response", on_response) + + ok = await _navigate(page, chapters_url) + if not ok: + mirror_chapters_url = _switch_to_mirror(chapters_url) + if mirror_chapters_url != chapters_url: + logger.info("Основной домен недоступен, пробуем зеркало: {}", mirror_chapters_url) + ok = await _navigate(page, mirror_chapters_url) + if ok: + chapters_url = mirror_chapters_url + base_manga_url = _switch_to_mirror(base_manga_url) + if not ok: + page.remove_listener("response", on_response) + return None + + # Ждём API-ответов (обычно приходят за 1-3 секунды) + for _ in range(30): + async with lock: + if chapters_api_data: + break + await asyncio.sleep(0.3) + + page.remove_listener("response", on_response) + + if chapters_auth_error and not chapters_api_data: + raise AuthRequiredError(self.slug) + + # Извлекаем pub_status из API манги (надёжнее DOM) + async with lock: + manga_meta = dict(manga_api_data) + pub_status = _pub_status_from_api(manga_meta) + if pub_status == "unknown": + pub_status = await _extract_pub_status(page) + + # Предпочитаем имена из API (надёжнее DOM и page.title) + async with lock: + manga_meta_snap = dict(manga_api_data) + title_ru = (manga_meta_snap.get("rus_name") or "").strip() + title_name = (manga_meta_snap.get("name") or "").strip() + if not title_ru: + title_ru = await _extract_title(page) + title_full = (f"{title_ru} / {title_name}" if title_name and title_ru and title_name != title_ru + else title_ru or title_name) + if not title_full: + try: + page_title = await page.title() + page_title = re.sub(r"\s*([-–|•]|читать|онлайн).*$", "", page_title, flags=re.IGNORECASE).strip() + title_full = page_title + except Exception: + pass + if not title_ru: + title_ru = title_full + + logger.info("Манга: {} | ru: {}", title_full, title_ru) + logger.info("Статус выпуска: {}", pub_status) + + description = await _extract_description(page) + genres = await _extract_genres(page) + + # Получаем обложку, описание и теги из API + async with lock: + manga_meta_for_extras = dict(manga_api_data) + + cover_url, extra_description, tags = await _fetch_extra_meta( + page, manga_meta_for_extras, url, self.auth_token + ) + if extra_description: + description = extra_description + if not description: + description = await _extract_description(page) + + async with lock: + raw_chapters = list(chapters_api_data) + + if raw_chapters: + chapters = _chapters_from_api(raw_chapters, base_manga_url) + else: + logger.warning("Chapters API не ответил, используем DOM-fallback") + chapters = await _chapters_from_dom(page, base_manga_url) + + logger.info("Найдено глав: {}", len(chapters)) + + return MangaInfo( + title=title_ru or title_full, + url=url, + chapters=chapters, + pub_status=pub_status, + title_ru=title_ru, + title_full=title_full, + description=description, + genres=genres, + tags=tags, + cover_url=cover_url, + ) + + # ────────────────────────────────────────────── + # Скачивание главы + # ────────────────────────────────────────────── + + async def get_chapter_images_and_download( + self, + page: Page, + chapter_url: str, + dest_dir: Path, + manga_url: Optional[str] = None, + on_page: object = None, + ) -> list[Path]: + """ + 1. Открывает страницу читалки. + 2. Пассивно наблюдает ответы через page.on("response"): + - api.cdnlibs.org/chapter? → список страниц + - api.cdnlibs.org/imageServers → серверы CDN + 3. Скачивает все страницы через page.context.request.get() + (разделяет cookies с браузером, без CORS-ограничений). + """ + t_start = time.monotonic() + ch_id = chapter_url.rstrip("/").split("/")[-1] + logger.info("[{}] Загружаем главу MangaLib: {}", ch_id, chapter_url) + + dest_dir.mkdir(parents=True, exist_ok=True) + referer_origin = _base_url(manga_url or chapter_url) + + chapter_api: dict = {} + image_servers: list = [] + chapter_auth_error: list = [] + lock = asyncio.Lock() + + async def on_response(resp): + resp_url = resp.url + try: + if "api.cdnlibs.org" in resp_url and "/chapter?" in resp_url: + if resp.status in (401, 403): + chapter_auth_error.append(True) + return + body = await resp.body() + data = _json.loads(body) + async with lock: + if not chapter_api.get("pages"): + chapter_api.update(data.get("data", {})) + elif "api.cdnlibs.org" in resp_url and "imageServers" in resp_url: + body = await resp.body() + data = _json.loads(body) + servers = data.get("data", {}).get("imageServers", []) + async with lock: + if not image_servers: + image_servers.extend(s["url"] for s in servers if "url" in s) + 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 referer_origin + ok = await _navigate(page, chapter_url, referer=referer) + if not ok: + mirror_chapter_url = _switch_to_mirror(chapter_url) + if mirror_chapter_url != chapter_url: + logger.info("[{}] Основной домен недоступен, пробуем зеркало: {}", ch_id, mirror_chapter_url) + mirror_referer = _switch_to_mirror(referer) if referer else referer + ok = await _navigate(page, mirror_chapter_url, referer=mirror_referer) + if ok: + chapter_url = mirror_chapter_url + referer_origin = _base_url(mirror_chapter_url) + if not ok: + page.remove_listener("response", on_response) + logger.error("[{}] Не удалось открыть главу: {}", ch_id, chapter_url) + return [] + + # Ждём ответ chapter API (обычно приходит за 1-3 секунды) + for _ in range(40): + async with lock: + if chapter_api.get("pages"): + break + await asyncio.sleep(0.5) + + page.remove_listener("response", on_response) + + if chapter_auth_error and not chapter_api.get("pages"): + raise AuthRequiredError(self.slug) + + async with lock: + pages_info = list(chapter_api.get("pages", [])) + servers_list = list(image_servers) + + if not pages_info: + try: + page_info = await page.evaluate("() => document.title + ' | ' + location.href") + except Exception: + page_info = "?" + logger.error("[{}] API не вернул данные страниц. Страница: {}", ch_id, page_info) + return [] + + total = len(pages_info) + logger.info("[{}] Страниц по API: {}", ch_id, total) + + # Строим маппинг: filename → 0-based index (slug 1-based) + fname_to_idx: dict[str, int] = {} + page_url_by_idx: dict[int, str] = {} + for p in pages_info: + try: + idx = int(p.get("slug", 0)) - 1 + if idx < 0: + continue + fname = p.get("image", "") + url_part = p.get("url", "") + if fname: + fname_to_idx[fname] = idx + if url_part: + page_url_by_idx[idx] = url_part + url_fname = url_part.rstrip("/").split("/")[-1] + if url_fname and url_fname not in fname_to_idx: + fname_to_idx[url_fname] = idx + except Exception: + pass + + # Определяем CDN сервер из img src или constants API + server = await _detect_server(page, servers_list) + logger.info("[{}] CDN сервер: {}", ch_id, server) + alt_servers = [s for s in servers_list if s != server] + + # Скачиваем все страницы через Playwright APIRequestContext + captured: dict[str, bytes] = {} + failed_idxs: list[int] = [] + all_servers = [server] + alt_servers + logger.info("[{}] Скачиваем {} страниц...", ch_id, total) + + for idx in range(total): + url_part = page_url_by_idx.get(idx, "") + if not url_part: + continue + fname = url_part.rstrip("/").split("/")[-1] + + body = None + for srv in all_servers: + body = await _api_fetch(page, srv + url_part, referer_origin) + if body: + if srv != server: + logger.debug("[{}] alt сервер OK: {} ({})", ch_id, fname, srv) + break + + if body: + captured[fname] = body + logger.debug("[{}] ✓ {}: {} байт", ch_id, fname, len(body)) + if on_page: + try: + asyncio.ensure_future(on_page(0, 0)) + except Exception: + pass + else: + failed_idxs.append(idx) + + # Retry провалившихся страниц с задержкой + if failed_idxs: + logger.info("[{}] Retry {} страниц с задержкой...", ch_id, len(failed_idxs)) + await asyncio.sleep(2) + for idx in failed_idxs: + url_part = page_url_by_idx.get(idx, "") + if not url_part: + continue + fname = url_part.rstrip("/").split("/")[-1] + body = None + for srv in all_servers: + body = await _api_fetch(page, srv + url_part, referer_origin) + if body: + break + if body: + captured[fname] = body + logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body)) + if on_page: + try: + asyncio.ensure_future(on_page(0, 0)) + except Exception: + pass + else: + logger.warning("[{}] Не удалось скачать: {}", ch_id, fname) + + elapsed = time.monotonic() - t_start + matched = sum(1 for f in captured if f in fname_to_idx) + logger.info("[{}] Скачано: {}/{} за {:.1f}с", ch_id, matched, total, elapsed) + + # Сохраняем файлы + paths: dict[int, Path] = {} + for fname, body in captured.items(): + idx = fname_to_idx.get(fname) + if idx is None: + continue + ext = _get_ext(fname) + p = dest_dir / f"{idx:04d}{ext}" + p.write_bytes(body) + paths[idx] = p + + missing_idxs = [i for i in range(total) if i not in paths] + if missing_idxs: + logger.warning("[{}] Пропущено {}/{} стр. №: {}", + ch_id, len(missing_idxs), total, [i + 1 for i in missing_idxs]) + + return [paths[i] for i in sorted(paths.keys())] + + +# ────────────────────────────────────────────── +# Вспомогательные функции (приватные) +# ────────────────────────────────────────────── + +# Зеркальные домены: при недоступности основного переключаемся на зеркало +_MIRROR_MAP = { + "mangalib.me": "mangalib.org", + "mangalib.org": "mangalib.me", + "hentailib.me": "mangalib.org", + "yaoilib.me": "mangalib.org", + "readlib.net": "mangalib.org", +} + + +def _switch_to_mirror(url: str) -> str: + """Заменяет домен в URL на зеркало из _MIRROR_MAP. Возвращает исходный URL если зеркала нет.""" + parsed = urlparse(url) + host = parsed.netloc.lower().removeprefix("www.") + mirror = _MIRROR_MAP.get(host) + if not mirror: + return url + return parsed._replace(netloc=mirror).geturl() + + +def _ensure_chapters_section(url: str) -> str: + if "section=chapters" in url: + return url + sep = "&" if "?" in url else "?" + return url + sep + "section=chapters" + + +def _manga_slug_from_url(url: str) -> str: + """Извлекает slug манги из URL страницы или главы. + + Примеры входных URL: + https://mangalib.me/ru/manga/11312--subete... → 11312--subete... + https://mangalib.me/ru/11312--subete.../read/v1/c1 → 11312--subete... + """ + parsed = urlparse(url) + parts = [p for p in parsed.path.split("/") if p] + # Убираем языковой префикс ('ru', 'en', ...) + if parts and len(parts[0]) <= 3 and parts[0].isalpha(): + parts = parts[1:] + # Убираем 'manga' если есть + if parts and parts[0] == "manga": + parts = parts[1:] + return parts[0] if parts else "" + + +def _chapters_from_api(raw: list, manga_url: str) -> list[Chapter]: + """Строит список глав из ответа api.cdnlibs.org/chapters.""" + parsed = urlparse(manga_url) + origin = f"{parsed.scheme}://{parsed.netloc}" + slug = _manga_slug_from_url(manga_url) + + # Определяем языковой префикс из оригинального URL (/ru/, /en/, ...) + path_parts = [p for p in parsed.path.split("/") if p] + lang_prefix = path_parts[0] if path_parts and len(path_parts[0]) <= 3 else "ru" + + chapters = [] + for ch in raw: + try: + vol = str(ch.get("volume") or "1") + num = str(ch.get("number") or "0") + name = ch.get("name") or "" + + try: + number_f = float(num) + except Exception: + number_f = 0.0 + try: + vol_i = int(float(vol)) + except Exception: + vol_i = 0 + + ch_url = f"{origin}/{lang_prefix}/{slug}/read/v{vol}/c{num}" + + title = f"Том {vol}, Глава {num}" + if name: + title += f" - {name}" + + chapters.append(Chapter(title=title, url=ch_url, number=number_f, volume=vol_i)) + except Exception as e: + logger.debug("Пропуск главы из API: {}", e) + + chapters.sort(key=lambda c: (c.volume, c.number)) + return chapters + + +async def _chapters_from_dom(page: Page, manga_url: str) -> list[Chapter]: + """Fallback: извлекает главы из DOM-ссылок вида /read/v{vol}/c{num}.""" + try: + raw = await page.evaluate(""" + () => { + const links = Array.from(document.querySelectorAll('a[href*="/read/v"]')); + const result = []; + const seen = new Set(); + for (const a of links) { + const href = a.href; + if (!href || seen.has(href)) continue; + if (!/\\/read\\/v\\d/.test(href)) continue; + const text = a.textContent.trim(); + // Пропускаем ссылки без нормального текста (кнопки навигации и т.п.) + if (!text || /^\\d+\\s*\\/\\s*\\d+$/.test(text)) continue; + seen.add(href); + result.push({ href, text }); + } + return result; + } + """) + if not raw: + return [] + + chapters = [] + for item in raw: + href = item["href"] + m = re.search(r"/read/v(\d+(?:\.\d+)?)/c(\d+(?:\.\d+)?)", href) + if not m: + continue + vol_s, num_s = m.group(1), m.group(2) + try: + number_f = float(num_s) + vol_i = int(float(vol_s)) + except Exception: + continue + text = item["text"] or f"Том {vol_s}, Глава {num_s}" + chapters.append(Chapter(title=text, url=href, number=number_f, volume=vol_i)) + + chapters.sort(key=lambda c: (c.volume, c.number)) + return chapters + except Exception as e: + logger.debug("_chapters_from_dom: {}", e) + return [] + + +def _pub_status_from_api(manga_meta: dict) -> str: + """Извлекает статус публикации из ответа API манги.""" + status = manga_meta.get("status", {}) + if isinstance(status, dict): + label = (status.get("label") or "").lower() + if "завершён" in label or "завершен" in label or "complete" in label: + return "completed" + if "продолжает" in label or "ongoing" in label or "выпускает" in label: + return "ongoing" + return "unknown" + + +async def _navigate(page: Page, url: str, retries: int = 3, + referer: str | None = None) -> bool: + if referer is None: + p = urlparse(url) + referer = f"{p.scheme}://{p.netloc}/" + for attempt in range(1, retries + 1): + try: + resp = await page.goto(url, wait_until="domcontentloaded", + timeout=60_000, referer=referer) + if resp and resp.status >= 400: + logger.warning("Попытка {}/{}: HTTP {}", attempt, retries, resp.status) + await asyncio.sleep(3 * attempt) + continue + try: + await page.wait_for_load_state("networkidle", timeout=15_000) + except Exception: + pass + return True + except Exception as e: + logger.warning("Попытка {}/{}: {}", attempt, retries, e) + await asyncio.sleep(3 * attempt) + return False + + +async def _extract_title(page: Page) -> str: + try: + result = await page.evaluate(""" + () => { + if (window.__DATA__ && window.__DATA__.manga) { + const m = window.__DATA__.manga; + return m.rus_name || m.name || ''; + } + const selectors = [ + '.media-name__main', + '.manga-name h1', + 'h1.media-title', + 'h1.page-title', + 'h1', + ]; + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el && el.textContent.trim()) return el.textContent.trim(); + } + return ''; + } + """) + return (result or "").strip() + except Exception: + return "" + + +async def _extract_pub_status(page: Page) -> str: + try: + result = await page.evaluate(""" + () => { + if (window.__DATA__ && window.__DATA__.manga && window.__DATA__.manga.status) { + const s = window.__DATA__.manga.status; + const label = (s.label || s.name || '').toLowerCase(); + if (label.includes('завершён') || label.includes('завершен') || label.includes('complete')) return 'completed'; + if (label.includes('продолжает') || label.includes('ongoing') || label.includes('выпускает')) return 'ongoing'; + } + const selectors = [ + '.media-info-item__status', + '.status-value', + '[class*="status"] .value', + '[class*="status"]', + ]; + for (const sel of selectors) { + const el = document.querySelector(sel); + if (!el) continue; + const t = el.textContent.toLowerCase(); + if (t.includes('завершён') || t.includes('завершен') || t.includes('complete')) return 'completed'; + if (t.includes('продолжает') || t.includes('ongoing')) return 'ongoing'; + } + return 'unknown'; + } + """) + return result or "unknown" + except Exception: + return "unknown" + + +async def _extract_description(page: Page) -> str: + try: + result = await page.evaluate(""" + () => { + if (window.__DATA__ && window.__DATA__.manga && window.__DATA__.manga.summary) { + return window.__DATA__.manga.summary; + } + const selectors = [ + '.media-description__text', + '.description-text', + '.manga-description', + '[class*="description"] p', + ]; + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el && el.textContent.trim()) return el.textContent.trim(); + } + return ''; + } + """) + return (result or "").strip()[:2000] + except Exception: + return "" + + +async def _extract_genres(page: Page) -> list[str]: + try: + result = await page.evaluate(""" + () => { + if (window.__DATA__ && window.__DATA__.manga && window.__DATA__.manga.genres) { + return window.__DATA__.manga.genres.map(g => g.name || g.label || '').filter(Boolean); + } + const selectors = [ + '.genre-list a', + '.media-tags a', + '.tags a', + '[class*="genre"] a', + ]; + for (const sel of selectors) { + const els = document.querySelectorAll(sel); + if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean); + } + return []; + } + """) + return result or [] + except Exception: + return [] + + +def _parse_summary_doc(doc) -> str: + """Конвертирует ProseMirror JSON-документ в plain text.""" + if not doc or not isinstance(doc, dict): + return "" + if doc.get("type") == "text": + return doc.get("text", "") + parts = [] + for node in doc.get("content", []): + text = _parse_summary_doc(node) + if text: + parts.append(text) + return " ".join(parts) + + +async def _fetch_extra_meta( + page: Page, + manga_api_data: dict, + manga_url: str, + auth_token: str | None, +) -> tuple[str, str, list[str]]: + """ + Возвращает (cover_url, description, tags) из уже полученных данных API или, + если нужных полей нет, делает явный supplementary-запрос к API. + """ + def _extract_from_data(data: dict) -> tuple[str, str, list[str]]: + cover_url = "" + cover_obj = data.get("cover") + if isinstance(cover_obj, dict): + cover_url = cover_obj.get("default") or cover_obj.get("thumbnail") or "" + + description = "" + summary = data.get("summary") + if summary: + if isinstance(summary, dict): + description = _parse_summary_doc(summary).strip() + elif isinstance(summary, str): + description = summary.strip() + + tags: list[str] = [] + for t in data.get("tags") or []: + name = (t.get("name") or t.get("label") or "").strip() + if name: + tags.append(name) + + return cover_url, description, tags + + cover_url, description, tags = _extract_from_data(manga_api_data) + + # Если хотя бы одного поля нет — делаем явный supplementary-запрос + if not cover_url or not description or not tags: + slug = _manga_slug_from_url(manga_url) + referer = _base_url(manga_url) + "/" + api_url = ( + f"https://api.cdnlibs.org/api/manga/{slug}" + "?fields[]=summary&fields[]=tags&fields[]=cover" + ) + try: + headers: dict = {"Referer": referer, "Accept": "application/json"} + if auth_token: + headers["Authorization"] = f"Bearer {auth_token}" + resp = await page.context.request.get(api_url, headers=headers) + if resp.ok: + body = await resp.body() + data = _json.loads(body).get("data", {}) + extra_cover, extra_desc, extra_tags = _extract_from_data(data) + if not cover_url: + cover_url = extra_cover + if not description: + description = extra_desc + if not tags: + tags = extra_tags + logger.debug("Supplementary API: cover={}, desc_len={}, tags={}", + bool(cover_url), len(description), len(tags)) + except Exception as e: + logger.debug("Supplementary API error: {}", e) + + return cover_url, description, tags + + +async def _detect_server(page: Page, servers_list: list[str]) -> str: + """Определяет CDN-сервер из img src на странице или из constants API.""" + try: + imgs = await page.evaluate("""() => + Array.from(document.querySelectorAll('img')).map(i => i.src) + .filter(s => s && /https?:\\/\\//.test(s) && /\\.(png|jpg|webp)/i.test(s)) + """) + for img_src in imgs: + m = re.match(r"(https?://[^/]+)", img_src) + if m: + srv = m.group(1) + if any(pat in srv for pat in ["mixlib", "imglib", "imgslib"]): + return srv + except Exception: + pass + if servers_list: + return servers_list[0] + return "https://img3.mixlib.me" + + +async def _api_fetch(page: Page, url: str, referer: str = "") -> bytes | None: + """ + Скачивает изображение через Playwright APIRequestContext. + Разделяет cookies с браузерным контекстом, не ограничен CORS. + """ + try: + headers: dict = {"Accept": "image/png,image/jpeg,image/webp,image/*,*/*"} + if referer: + headers["Referer"] = referer + response = await page.context.request.get(url, headers=headers) + if response.ok: + body = await response.body() + return body if len(body) > 500 else None + except Exception: + pass + return None + + +def _get_ext(url: str) -> str: + m = re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, re.IGNORECASE) + if m: + ext = m.group(1).lower() + return ".jpg" if ext == "jpeg" else f".{ext}" + return ".jpg" + + +def _base_url(url: str) -> str: + m = re.match(r"(https?://[^/]+)", url) + return m.group(1) if m else "https://mangalib.me" diff --git a/src/sources/readmanga.py b/src/sources/readmanga.py index 74be341..a6d1255 100644 --- a/src/sources/readmanga.py +++ b/src/sources/readmanga.py @@ -47,6 +47,8 @@ class ReadmangaSource: description = await _extract_description(page) genres = await _extract_genres(page) + tags = await _extract_tags(page) + cover_url = await _get_cover_url(page) await _expand_chapters(page) chapters = await _extract_chapters(page) @@ -63,6 +65,8 @@ class ReadmangaSource: title_full=title_full, description=description, genres=genres, + tags=tags, + cover_url=cover_url, ) # ────────────────────────────────────────────── @@ -115,13 +119,22 @@ class ReadmangaSource: route_errors: dict[str, str] = {} route_statuses: dict[str, int] = {} lock = asyncio.Lock() + # Имена файлов из readerInit — заполняются после парсинга страницы. + # Позволяет перехватывать картинки с незнакомых CDN-доменов (например, при VPN). + expected_filenames: set[str] = set() async def route_handler(route, request): url = request.url base = _base(url) + fname = base.split("/")[-1] if not _is_manga_image(url): - await route.continue_() - return + # Fallback: домен не в cdn_patterns, но имя файла совпадает с readerInit — + # значит CDN сменился (VPN, балансировка). Перехватываем. + if not expected_filenames or fname not in expected_filenames: + await route.continue_() + return + logger.debug("[{}] CDN fallback: {} (unknown domain: {})", + ch_id, fname, url.split("/")[2]) if BANNER_RE.search(base): await route.continue_() return @@ -201,6 +214,8 @@ class ReadmangaSource: url_to_idx = {_base(u): i for i, u in enumerate(image_urls)} filename_to_idx = {_base(u).split("/")[-1]: i for i, u in enumerate(image_urls)} total = len(image_urls) + # Активируем CDN-fallback в route_handler: теперь он знает ожидаемые имена файлов + expected_filenames.update(filename_to_idx.keys()) def _count_matched() -> int: count = 0 @@ -236,40 +251,73 @@ class ReadmangaSource: await asyncio.sleep(3) - # Retry timeout через JS fetch + async def _js_fetch(url: str) -> bytes | None: + """Скачивает изображение через JS fetch в контексте браузера.""" + try: + data_b64 = await page.evaluate("""async (url) => { + try { + const r = await fetch(url, {credentials: 'include'}); + if (!r.ok) return null; + const buf = await r.arrayBuffer(); + const bytes = new Uint8Array(buf); + let bin = ''; + for (let b of bytes) bin += String.fromCharCode(b); + return btoa(bin); + } catch(e) { return null; } + }""", url) + if data_b64: + body = base64.b64decode(data_b64) + return body if len(body) > 500 else None + except Exception: + pass + return None + + # Retry 1: timeout-ошибки через JS fetch async with lock: timeout_bases = [u for u, e in route_errors.items() if "timeout" in e.lower() and u not in captured] if timeout_bases: logger.info("[{}] Retry {} страниц с timeout...", ch_id, len(timeout_bases)) for retry_base in timeout_bases: - if retry_base in captured: - continue + async with lock: + if retry_base in captured: + continue fname = retry_base.split("/")[-1] - try: - data_b64 = await page.evaluate("""async (url) => { - try { - const r = await fetch(url, {credentials: 'include'}); - if (!r.ok) return null; - const buf = await r.arrayBuffer(); - const bytes = new Uint8Array(buf); - let bin = ''; - for (let b of bytes) bin += String.fromCharCode(b); - return btoa(bin); - } catch(e) { return null; } - }""", retry_base) - if data_b64: - body = base64.b64decode(data_b64) - if len(body) > 500: - async with lock: - captured[retry_base] = body - logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body)) - else: - logger.warning("[{}] Retry вернул {} байт — игнорируем", ch_id, len(body)) - else: - logger.warning("[{}] Retry null для '{}'", ch_id, fname) - except Exception as e2: - logger.warning("[{}] Retry JS ошибка '{}': {}", ch_id, fname, e2) + body = await _js_fetch(retry_base) + if body: + async with lock: + captured[retry_base] = body + logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body)) + else: + logger.warning("[{}] Retry null для '{}'", ch_id, fname) + + # Retry 2: не_перехваченные — CDN-домен сменился (VPN, балансировка). + # Браузер их загрузил, но route_handler не захватил байты. + # Берём URL напрямую из readerInit и достаём через JS fetch. + async with lock: + captured_fnames = {b.split("/")[-1] for b in captured} + unperceived = [ + _base(u) for u in image_urls + if _base(u).split("/")[-1] not in captured_fnames + and _base(u) not in route_errors + and _base(u) not in route_statuses + ] + if unperceived: + logger.info("[{}] JS retry для {} не_перехваченных (CDN-домен?)..", + ch_id, len(unperceived)) + for retry_base in unperceived: + async with lock: + if retry_base.split("/")[-1] in captured_fnames: + continue + fname = retry_base.split("/")[-1] + body = await _js_fetch(retry_base) + if body: + async with lock: + captured[retry_base] = body + captured_fnames.add(fname) + logger.info("[{}] CDN retry OK: {} ({} байт)", ch_id, fname, len(body)) + else: + logger.warning("[{}] CDN retry null для '{}'", ch_id, fname) await page.unroute("**/*", route_handler) @@ -430,6 +478,18 @@ async def _extract_description(page: Page) -> str: try: result = await page.evaluate(""" () => { + // Приоритетный селектор — новый сайт ReadManga + const crDesc = document.querySelector('.cr-description__content'); + if (crDesc) { + const parts = []; + crDesc.querySelectorAll('p, span, div').forEach(el => { + const t = el.textContent.trim(); + if (t) parts.push(t); + }); + if (parts.length) return parts.join(' '); + const t = crDesc.textContent.trim(); + if (t) return t; + } const selectors = [ '.manga-description', '.elem_descr .value', '#tab-description .description-text', '.description', @@ -447,6 +507,42 @@ async def _extract_description(page: Page) -> str: return "" +async def _extract_tags(page: Page) -> list[str]: + try: + result = await page.evaluate(""" + () => { + const crTags = document.querySelector('.cr-tags'); + if (crTags) { + const els = crTags.querySelectorAll('a, span, li'); + if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean); + const t = crTags.textContent.trim(); + if (t) return t.split(/[,;]/).map(s => s.trim()).filter(Boolean); + } + return []; + } + """) + return result or [] + except Exception: + return [] + + +async def _get_cover_url(page: Page) -> str: + try: + result = await page.evaluate(""" + () => { + const wrapper = document.querySelector('.cr-hero-poster-wrapper'); + if (wrapper) { + const img = wrapper.querySelector('img'); + if (img) return img.src || img.dataset.src || ''; + } + return ''; + } + """) + return (result or "").strip() + except Exception: + return "" + + async def _extract_genres(page: Page) -> list[str]: try: result = await page.evaluate(""" diff --git a/src/state.py b/src/state.py index 5553095..58abe0d 100644 --- a/src/state.py +++ b/src/state.py @@ -20,6 +20,32 @@ _DEFAULT_READMANGA_DOMAINS = [ "3.readmanga.ru", ] +# Домены MangaLib по умолчанию (сидинг при первом запуске) +_DEFAULT_MANGALIB_DOMAINS = [ + "mangalib.me", + "mangalib.org", + "hentailib.me", + "yaoilib.me", + "readlib.net", +] + +_ALLOWED_FMTS = frozenset({"cbz", "pdf", "epub"}) + + +def _now() -> str: + return datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + + +def _extract_domain(url: str) -> str: + """Извлекает домен без www.""" + try: + domain = urlparse(url).netloc.lower() + if domain.startswith("www."): + domain = domain[4:] + return domain + except Exception: + return "" + class StateDB: def __init__(self, db_path: Path = DB_PATH): @@ -46,7 +72,11 @@ class StateDB: added_at TEXT, updated_at TEXT, started_at TEXT, - finished_at TEXT + finished_at TEXT, + folder_name TEXT, + source_id INTEGER REFERENCES sources(id), + added_by INTEGER REFERENCES users(id), + last_error TEXT ) """) self.conn.execute(""" @@ -128,7 +158,11 @@ class StateDB: ("mangas", "folder_name", "TEXT"), ("mangas", "source_id", "INTEGER REFERENCES sources(id)"), ("mangas", "added_by", "INTEGER REFERENCES users(id)"), + ("mangas", "last_error", "TEXT"), ("users", "is_env_admin", "INTEGER NOT NULL DEFAULT 0"), + ("mangas", "description", "TEXT"), + ("mangas", "tags", "TEXT"), + ("mangas", "cover_url", "TEXT"), ] for table, col, typedef in migrations: try: @@ -180,6 +214,24 @@ class StateDB: self.conn.commit() logger.info("Сидинг доменов ReadManga: {} доменов", len(_DEFAULT_READMANGA_DOMAINS)) + # Сидинг доменов MangaLib при первом запуске + ml = self.conn.execute("SELECT id FROM sources WHERE slug='mangalib'").fetchone() + if ml: + count = self.conn.execute( + "SELECT COUNT(*) FROM source_domains WHERE source_id=?", (ml["id"],) + ).fetchone()[0] + if count == 0: + for domain in _DEFAULT_MANGALIB_DOMAINS: + try: + self.conn.execute( + "INSERT INTO source_domains (source_id, domain) VALUES (?,?)", + (ml["id"], domain) + ) + except Exception: + pass + self.conn.commit() + logger.info("Сидинг доменов MangaLib: {} доменов", len(_DEFAULT_MANGALIB_DOMAINS)) + # Логируем источники в БД без кода (не в реестре) known_slugs = set(registry.all_slugs()) db_slugs = [r["slug"] for r in self.conn.execute("SELECT slug FROM sources").fetchall()] @@ -321,11 +373,16 @@ class StateDB: def update_manga_info(self, url: str, title: str, chapters_total: int, title_ru: str = "", title_full: str = "", - pub_status: str = "unknown"): + pub_status: str = "unknown", + description: str = "", tags: str = "", + cover_url: str = ""): self.conn.execute(""" UPDATE mangas SET title=?, title_ru=?, title_full=?, pub_status=?, - chapters_total=?, updated_at=? WHERE url=? - """, (title, title_ru, title_full, pub_status, chapters_total, _now(), url)) + chapters_total=?, updated_at=?, + description=?, tags=?, cover_url=? + WHERE url=? + """, (title, title_ru, title_full, pub_status, chapters_total, _now(), + description or None, tags or None, cover_url or None, url)) self.conn.commit() def set_folder_name(self, url: str, folder_name: str): @@ -372,6 +429,26 @@ class StateDB: """, (status, _now(), url)) self.conn.commit() + def set_manga_last_error(self, manga_url: str, error: Optional[str]) -> None: + self.conn.execute( + "UPDATE mangas SET last_error=?, updated_at=? WHERE url=?", + (error, _now(), manga_url) + ) + self.conn.commit() + + def get_mangas_by_source(self, source_id: int) -> list[dict]: + cur = self.conn.execute( + "SELECT url, last_error FROM mangas WHERE source_id=?", (source_id,) + ) + return [dict(r) for r in cur.fetchall()] + + def update_source_settings(self, source_id: int, settings: dict) -> None: + self.conn.execute( + "UPDATE sources SET settings=? WHERE id=?", + (json.dumps(settings), source_id) + ) + self.conn.commit() + def mark_started(self, url: str) -> str: """Записывает время начала загрузки. Возвращает timestamp.""" ts = _now() @@ -673,21 +750,3 @@ class StateDB: self.conn.close() -_ALLOWED_FMTS = frozenset({"cbz", "pdf", "epub"}) - - -def _now() -> str: - return datetime.now(timezone.utc).replace(tzinfo=None).isoformat() - - -def _extract_domain(url: str) -> str: - """Извлекает домен без www.""" - try: - domain = urlparse(url).netloc.lower() - if domain.startswith("www."): - domain = domain[4:] - return domain - except Exception: - return "" - - diff --git a/src/worker.py b/src/worker.py index 953612e..cef844c 100644 --- a/src/worker.py +++ b/src/worker.py @@ -11,7 +11,8 @@ from loguru import logger from .browser import BrowserManager from .sources import registry, get_source_for_url, extract_domain -from .sources.base import Chapter, MangaInfo +import json as _json +from .sources.base import Chapter, MangaInfo, AuthRequiredError from .exporter import export, MangaMeta from .state import StateDB from .utils import safe_name, safe_chapter_name @@ -66,18 +67,39 @@ async def download_manga( "error": "Источник не определён. Выберите источник в настройках манги."}) return + # Inject auth token from source DB settings + 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: ctx, info_page = await bm.new_page() - manga = await source.get_manga_info(info_page, url) - await info_page.close() + try: + manga = await source.get_manga_info(info_page, url) + except AuthRequiredError as e: + await info_page.close() + await db_call(db.update_manga_status, url, "stopped") + await db_call(db.set_manga_last_error, url, f"auth_required:{e.source_slug}") + finished_ts = await db_call(db.mark_finished, url) + await emit({"type": "auth_required", "url": url, + "source_slug": e.source_slug, "finished_at": finished_ts}) + return if not manga: + await info_page.close() await db_call(db.update_manga_status, url, "failed") await emit({"type": "manga_failed", "url": url, "error": "Не удалось получить информацию о манге"}) return + import json as _json_mod await db_call( db.update_manga_info, url, @@ -86,6 +108,9 @@ async def download_manga( title_ru=manga.title_ru, title_full=manga.title_full, pub_status=manga.pub_status, + description=manga.description, + tags=_json_mod.dumps(manga.tags, ensure_ascii=False) if manga.tags else "", + cover_url=manga.cover_url, ) await emit({ "type": "manga_info", @@ -106,6 +131,12 @@ async def download_manga( manga_dir = output_dir / folder_name manga_dir.mkdir(parents=True, exist_ok=True) + # Скачиваем обложку для CBZ-формата (info_page ещё открыта — контекст браузера жив) + if manga.cover_url and fmt in ("cbz", "all"): + await _download_cover(manga.cover_url, manga_dir, url, info_page) + + await info_page.close() + for ch in manga.chapters: await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume) @@ -229,6 +260,7 @@ async def download_manga( source_url=url, summary=manga.description, genre=", ".join(manga.genres) if manga.genres else "", + tags=", ".join(manga.tags) if manga.tags else "", ) for f in formats: out_file = manga_dir / f"{ch_name}.{f}" @@ -267,6 +299,8 @@ async def download_manga( "chapters_total": len(manga.chapters), }) + except AuthRequiredError: + raise except Exception as e: logger.exception( "Необработанное исключение в Т{} Гл.{} '{}' | {}: {}", @@ -282,14 +316,70 @@ async def download_manga( tasks = [process_chapter(ch) for ch in to_download] results = await asyncio.gather(*tasks, return_exceptions=True) - # Логируем неожиданные исключения из gather + # Логируем неожиданные исключения из gather; обнаруживаем auth ошибки + auth_slug = None for ch, res in zip(to_download, results): - if isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError): + if isinstance(res, AuthRequiredError): + auth_slug = res.source_slug + elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError): logger.exception( "gather: необработанное исключение Т{} Гл.{} '{}': {}", ch.volume, ch.number, ch.title, res, ) + if auth_slug: + await db_call(db.update_manga_status, url, "stopped") + await db_call(db.set_manga_last_error, url, f"auth_required:{auth_slug}") + finished_ts = await db_call(db.mark_finished, url) + await emit({"type": "auth_required", "url": url, + "source_slug": auth_slug, "finished_at": finished_ts}) + return + + # ── Автоповтор неудачных глав (до 3 раз) ───────────────────── + MAX_AUTO_RETRIES = 3 + for retry_attempt in range(1, MAX_AUTO_RETRIES + 1): + stats = await db_call(db.get_chapter_stats, url) + if stats["failed"] + stats["partial"] == 0: + break + failed_count = stats["failed"] + stats["partial"] + logger.info( + "Автоповтор {}/{}: {} неудачных/частичных глав для {}", + retry_attempt, MAX_AUTO_RETRIES, failed_count, url, + ) + await emit({ + "type": "retry_errors_auto", + "url": url, + "attempt": retry_attempt, + "max_attempts": MAX_AUTO_RETRIES, + "failed_count": failed_count, + }) + await db_call(db.reset_failed_chapters, url) + all_ch_rows = await db_call(db.get_all_chapters, url) + pending_urls = {c["chapter_url"] for c in all_ch_rows if c["status"] == "pending"} + retry_chapters = [ch for ch in manga.chapters if ch.url in pending_urls] + if not retry_chapters: + break + retry_results = await asyncio.gather( + *[process_chapter(ch) for ch in retry_chapters], + return_exceptions=True, + ) + auth_slug = None + for ch, res in zip(retry_chapters, retry_results): + if isinstance(res, AuthRequiredError): + auth_slug = res.source_slug + elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError): + logger.exception( + "retry {}: необработанное исключение Т{} Гл.{} '{}': {}", + retry_attempt, ch.volume, ch.number, ch.title, res, + ) + if auth_slug: + await db_call(db.update_manga_status, url, "stopped") + await db_call(db.set_manga_last_error, url, f"auth_required:{auth_slug}") + finished_ts = await db_call(db.mark_finished, url) + await emit({"type": "auth_required", "url": url, + "source_slug": auth_slug, "finished_at": finished_ts}) + return + real_done = await db_call(db.sync_chapters_done, url) await db_call(db.update_manga_status, url, "done") finished_ts = await db_call(db.mark_finished, url) @@ -316,6 +406,43 @@ async def download_manga( 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) + if m: + ext = m.group(1).lower() + return ".jpg" if ext == "jpeg" else f".{ext}" + return ".jpg" + + +async def _download_cover(cover_url: str, manga_dir: Path, manga_url: str, page) -> Optional[Path]: + """Скачивает обложку в manga_dir/cover.{ext}. Использует существующий Playwright page.""" + from urllib.parse import urlparse as _urlparse + try: + parsed = _urlparse(manga_url) + referer = f"{parsed.scheme}://{parsed.netloc}/" + headers = { + "Accept": "image/png,image/jpeg,image/webp,image/*,*/*", + "Referer": referer, + } + response = await page.context.request.get(cover_url, headers=headers) + if not response.ok: + logger.warning("Обложка: HTTP {} для {}", response.status, cover_url) + return None + body = await response.body() + if len(body) < 500: + logger.warning("Обложка: слишком малый ответ ({} байт)", len(body)) + return None + ext = _cover_ext_from_url(cover_url) + cover_path = manga_dir / f"cover{ext}" + cover_path.write_bytes(body) + logger.info("Обложка сохранена: {} ({} байт)", cover_path.name, len(body)) + return cover_path + except Exception as e: + logger.warning("Ошибка скачивания обложки {}: {}", cover_url, e) + return None + + async def check_for_updates( url: str, on_event: Optional[Callable] = None, @@ -332,15 +459,21 @@ async def check_for_updates( pass db = StateDB() + db_lock = asyncio.Lock() + + async def db_call(fn, *args, **kwargs): + async with db_lock: + return fn(*args, **kwargs) + try: - db.set_last_checked(url) - db.add_history(manga_url=url, event_type="check_started") + await db_call(db.set_last_checked, url) + await db_call(db.add_history, manga_url=url, event_type="check_started") await emit({"type": "check_started", "url": url}) # Резолвим источник source = get_source_for_url(url, db) if source is None: - manga_row = db.get_manga(url) + 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: @@ -350,27 +483,47 @@ async def check_for_updates( async with BrowserManager(headless=True) as bm: _, page = await bm.new_page() manga = await source.get_manga_info(page, url) - await page.close() if not manga: + await page.close() return [] - # Обновляем pub_status и количество глав - db.update_manga_info( + import json as _json_mod + # Обновляем pub_status, количество глав и мета-поля + 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_mod.dumps(manga.tags, ensure_ascii=False) if manga.tags else "", + cover_url=manga.cover_url, ) + # Обновляем обложку если манга сохраняется как cbz + manga_row = await db_call(db.get_manga, url) + manga_fmt = (manga_row or {}).get("format", "cbz") + if manga.cover_url and manga_fmt in ("cbz", "all"): + 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_dir.exists(): + await _download_cover(manga.cover_url, manga_dir, url, page) + + await page.close() + # Находим главы которых ещё нет в БД - known = {ch["chapter_url"] for ch in db.get_all_chapters(url)} + known = {ch["chapter_url"] for ch in await db_call(db.get_all_chapters, url)} new_chapters = [ch for ch in manga.chapters if ch.url not in known] for ch in new_chapters: - db.upsert_chapter(url, ch.url, ch.title, ch.number, ch.volume) - db.add_history( + await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume) + await db_call( + db.add_history, manga_url=url, event_type="new_chapter_found", chapter_url=ch.url, @@ -386,7 +539,8 @@ async def check_for_updates( "chapter_number": ch.number, }) - db.add_history( + await db_call( + db.add_history, manga_url=url, event_type="check_done", details=f"Найдено новых: {len(new_chapters)}",