upd
This commit is contained in:
369
CODE_REVIEW.md
Normal file
369
CODE_REVIEW.md
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
# 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
|
||||||
|
`<button onclick="openEditUserModal(${u.id}, '${escHtml(u.username)}', ...)">
|
||||||
|
```
|
||||||
|
|
||||||
|
`escHtml` экранирует HTML-спецсимволы (`<`, `>`, `&`), но не защищает от инъекции в контекст JavaScript-строки. Если `u.username` содержит `'); evil() //` — экранирование не поможет.
|
||||||
|
|
||||||
|
**Исправление:** вместо `onclick="..."` в строке шаблона использовать `data-*` атрибуты + делегирование событий:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
`<button class="edit-user-btn" data-id="${u.id}" data-username="${escAttr(u.username)}">`
|
||||||
|
|
||||||
|
// один раз:
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.edit-user-btn');
|
||||||
|
if (btn) openEditUserModal(+btn.dataset.id, btn.dataset.username, ...);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Дублирование `forceRedownload` / `forceRedownloadModal`
|
||||||
|
|
||||||
|
**Файл:** `frontend/index.html`
|
||||||
|
|
||||||
|
Две функции с практически идентичным телом — `forceRedownload(url)` и `forceRedownloadModal(url)`. Отличие только в том, что вторая закрывает модал перед вызовом.
|
||||||
|
|
||||||
|
**Исправление:** одна функция с необязательным флагом или вызов `closeModal()` перед `forceRedownload()` там, где нужно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. `check_for_updates` в `worker.py` импортирует `scraper` шим
|
||||||
|
|
||||||
|
**Файл:** `src/worker.py:16`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .scraper import get_manga_info, get_chapter_images_and_download # shim для обратной совместимости
|
||||||
|
```
|
||||||
|
|
||||||
|
Эти символы нигде в файле не используются — `worker.py` работает через `registry`. Мёртвый импорт.
|
||||||
|
|
||||||
|
**Исправление:** удалить строку.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Одиночный SQLite-коннект с `check_same_thread=False` в async-среде
|
||||||
|
|
||||||
|
**Файл:** `src/state.py:27`
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый `StateDB()` создаёт новый коннект, но внутри одного воркера `db` живёт на всё время скачивания манги. `check_same_thread=False` отключает встроенную защиту SQLite от использования коннекта в нескольких потоках. В текущей архитектуре с `asyncio` и `db_lock` это безопасно, но хрупко — одно забытое `await` без блокировки может привести к `database is locked` или повреждению данных.
|
||||||
|
|
||||||
|
**Рекомендация:** ничего не менять прямо сейчас, но при следующем крупном рефакторинге рассмотреть `aiosqlite` или SQLite WAL-режим (`PRAGMA journal_mode=WAL`) для снижения вероятности блокировок при параллельных читателях.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводная таблица приоритетов
|
||||||
|
|
||||||
|
| # | Файл | Проблема | Приоритет |
|
||||||
|
|---|------|----------|-----------|
|
||||||
|
| 2 | api.py | Прямой `db.conn` в эндпоинтах (retry, force, delete) | Высокий |
|
||||||
|
| 8 | api.py | O(n²) очередь позиций | Высокий |
|
||||||
|
| 13 | frontend | Двойное чтение `r.json()` — баг | Высокий |
|
||||||
|
| 4 | worker.py | `check_for_updates` без `db_lock` | Средний |
|
||||||
|
| 9 | exporter.py | Утечка PIL-объектов в PDF Pillow | Средний |
|
||||||
|
| 10 | state.py | Хрупкий f-string в `mark_done` | Средний |
|
||||||
|
| 3 | api/state | `datetime.utcnow()` deprecated | Низкий |
|
||||||
|
| 1 | api/worker/cli | Дублирование `_safe_name` | Низкий |
|
||||||
|
| 5 | worker.py | Хак `pages_done_count = [0]` | Низкий |
|
||||||
|
| 6 | state.py | Мёртвый метод `increment_manga_chapters_done` | Низкий |
|
||||||
|
| 7 | api.py | Поздний `import` внутри функций | Низкий |
|
||||||
|
| 11 | browser.py | Мёртвый метод `navigate()` | Низкий |
|
||||||
|
| 12 | cli.py | Устаревший шим, нет поддержки реестра | Низкий |
|
||||||
|
| 14 | frontend | JS-инъекция через `onclick` + `escHtml` | Низкий (internal tool) |
|
||||||
|
| 15 | frontend | Дублирование `forceRedownload*` | Низкий |
|
||||||
|
| 16 | worker.py | Мёртвый импорт scraper shim | Низкий |
|
||||||
|
| 17 | state.py | `check_same_thread=False` в async | На будущее |
|
||||||
@@ -1078,6 +1078,7 @@ async function resumeManga(url) {
|
|||||||
|
|
||||||
// ── Users management (admin only) ─────────────
|
// ── Users management (admin only) ─────────────
|
||||||
let _userModalEditId = null;
|
let _userModalEditId = null;
|
||||||
|
const _userDataCache = {};
|
||||||
|
|
||||||
function showUsersSection() {
|
function showUsersSection() {
|
||||||
if(isAdmin()) {
|
if(isAdmin()) {
|
||||||
@@ -1103,6 +1104,7 @@ function renderUsers(users) {
|
|||||||
el.innerHTML = '<div class="text-xs text-gray-500">Нет пользователей</div>';
|
el.innerHTML = '<div class="text-xs text-gray-500">Нет пользователей</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
users.forEach(u => { _userDataCache[u.id] = u; });
|
||||||
const roleColors = {admin: 'color:#fbbf24;background:#292103', user: 'color:#86efac;background:#032911'};
|
const roleColors = {admin: 'color:#fbbf24;background:#292103', user: 'color:#86efac;background:#032911'};
|
||||||
el.innerHTML = users.map(u => `
|
el.innerHTML = users.map(u => `
|
||||||
<div class="flex items-center justify-between px-3 py-2 rounded-lg" style="background:#1e293b">
|
<div class="flex items-center justify-between px-3 py-2 rounded-lg" style="background:#1e293b">
|
||||||
@@ -1114,13 +1116,25 @@ function renderUsers(users) {
|
|||||||
${u.is_env_admin ? '<span class="text-xs text-gray-500" title="Системный администратор — пароль задаётся через AUTH_PASSWORD">🔒</span>' : ''}
|
${u.is_env_admin ? '<span class="text-xs text-gray-500" title="Системный администратор — пароль задаётся через AUTH_PASSWORD">🔒</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button onclick="openEditUserModal(${u.id}, '${escHtml(u.username)}', '${u.role}', ${!!u.is_env_admin})"
|
<button data-action="edit-user" data-id="${u.id}"
|
||||||
class="text-xs px-2 py-1 rounded text-gray-400 hover:text-white" style="background:#334155">✏️</button>
|
class="text-xs px-2 py-1 rounded text-gray-400 hover:text-white" style="background:#334155">✏️</button>
|
||||||
${!u.is_env_admin && u.id !== state.currentUser?.id ? `<button onclick="confirmDeleteUser(${u.id}, '${escHtml(u.username)}')"
|
${!u.is_env_admin && u.id !== state.currentUser?.id ? `<button data-action="delete-user" data-id="${u.id}"
|
||||||
class="text-xs px-2 py-1 rounded text-red-400 hover:text-red-300" style="background:#2d1111">✕</button>` : ''}
|
class="text-xs px-2 py-1 rounded text-red-400 hover:text-red-300" style="background:#2d1111">✕</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
el.querySelectorAll('[data-action="edit-user"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const u = _userDataCache[+btn.dataset.id];
|
||||||
|
if(u) openEditUserModal(u.id, u.username, u.role, !!u.is_env_admin);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
el.querySelectorAll('[data-action="delete-user"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const u = _userDataCache[+btn.dataset.id];
|
||||||
|
if(u) confirmDeleteUser(u.id, u.username);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddUserModal() {
|
function openAddUserModal() {
|
||||||
@@ -1523,23 +1537,18 @@ async function refreshMetaModal(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function forceRedownload(url) {
|
async function forceRedownload(url, closeModalAfter = false) {
|
||||||
if(!confirm('Скачать заново ВСЕ главы? Уже скачанные файлы будут перезаписаны.')) return;
|
if(!confirm('Скачать заново ВСЕ главы? Уже скачанные файлы будут перезаписаны.')) return;
|
||||||
const r = await fetch('/api/mangas/force_redownload?url='+encodeURIComponent(url), {method:'POST'});
|
const r = await fetch('/api/mangas/force_redownload?url='+encodeURIComponent(url), {method:'POST'});
|
||||||
if(r.ok && state.mangas[url]) {
|
if(r.ok && state.mangas[url]) {
|
||||||
state.mangas[url].status = 'queued';
|
state.mangas[url].status = 'queued';
|
||||||
updateMangaRow(url);
|
updateMangaRow(url);
|
||||||
}
|
}
|
||||||
|
if(closeModalAfter) closeModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function forceRedownloadModal(url) {
|
async function forceRedownloadModal(url) {
|
||||||
if(!confirm('Скачать заново ВСЕ главы? Уже скачанные файлы будут перезаписаны.')) return;
|
return forceRedownload(url, true);
|
||||||
const r = await fetch('/api/mangas/force_redownload?url='+encodeURIComponent(url), {method:'POST'});
|
|
||||||
if(r.ok && state.mangas[url]) {
|
|
||||||
state.mangas[url].status = 'queued';
|
|
||||||
updateMangaRow(url);
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openDetail(url, initialTab = 'overview') {
|
async function openDetail(url, initialTab = 'overview') {
|
||||||
@@ -2280,8 +2289,8 @@ async function saveRenameFolder() {
|
|||||||
headers: {'Content-Type':'application/json'},
|
headers: {'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({url: _renameFolderUrl, folder_name}),
|
body: JSON.stringify({url: _renameFolderUrl, folder_name}),
|
||||||
});
|
});
|
||||||
if(!r.ok) throw new Error((await r.json()).detail || await r.text());
|
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
|
if(!r.ok) throw new Error(data.detail || 'Ошибка сервера');
|
||||||
if(state.mangas[_renameFolderUrl]) {
|
if(state.mangas[_renameFolderUrl]) {
|
||||||
state.mangas[_renameFolderUrl].folder_name = data.folder_name;
|
state.mangas[_renameFolderUrl].folder_name = data.folder_name;
|
||||||
}
|
}
|
||||||
|
|||||||
80
src/api.py
80
src/api.py
@@ -4,7 +4,7 @@ FastAPI веб-сервер: REST API + WebSocket для мониторинга
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import re
|
import shutil
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
@@ -16,9 +16,11 @@ 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
|
from .worker import download_manga, check_for_updates
|
||||||
|
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
|
||||||
from .auth import verify_password, hash_password, generate_session_token, COOKIE_NAME, COOKIE_MAX_AGE
|
from .auth import verify_password, hash_password, generate_session_token, COOKIE_NAME, COOKIE_MAX_AGE
|
||||||
|
from .utils import safe_name
|
||||||
OUTPUT_DIR = Path("/app/output")
|
OUTPUT_DIR = Path("/app/output")
|
||||||
FRONTEND_DIR = Path("/app/frontend")
|
FRONTEND_DIR = Path("/app/frontend")
|
||||||
app = FastAPI(title="Manga Downloader API")
|
app = FastAPI(title="Manga Downloader API")
|
||||||
@@ -247,13 +249,11 @@ async def _run_auto_updates():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка авто-обновления {}: {}", url, e)
|
logger.error("Ошибка авто-обновления {}: {}", url, e)
|
||||||
# ── Helpers ───────────────────────────────────
|
# ── Helpers ───────────────────────────────────
|
||||||
def _safe_name(s: str) -> str:
|
|
||||||
return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80]
|
|
||||||
def _manga_folder(m: dict) -> Path:
|
def _manga_folder(m: dict) -> Path:
|
||||||
if m.get("folder_name"):
|
if m.get("folder_name"):
|
||||||
return OUTPUT_DIR / m["folder_name"]
|
return OUTPUT_DIR / m["folder_name"]
|
||||||
title = m.get("title") or ""
|
title = m.get("title") or ""
|
||||||
return OUTPUT_DIR / _safe_name(title)
|
return OUTPUT_DIR / safe_name(title)
|
||||||
def _dir_size(path: Path) -> int:
|
def _dir_size(path: Path) -> int:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return 0
|
return 0
|
||||||
@@ -266,16 +266,7 @@ def _format_size(bytes_val: int) -> str:
|
|||||||
return f"{bytes_val:.1f} ТБ"
|
return f"{bytes_val:.1f} ТБ"
|
||||||
def _enrich_manga(m: dict, db: StateDB) -> dict:
|
def _enrich_manga(m: dict, db: StateDB) -> dict:
|
||||||
size_bytes = _dir_size(_manga_folder(m))
|
size_bytes = _dir_size(_manga_folder(m))
|
||||||
ch_done_count = db.conn.execute(
|
stats = db.get_chapter_stats(m["url"])
|
||||||
"SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'", (m["url"],)
|
|
||||||
).fetchone()[0]
|
|
||||||
ch_failed = db.conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='failed'", (m["url"],)
|
|
||||||
).fetchone()[0]
|
|
||||||
ch_partial = db.conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'"
|
|
||||||
" AND pages_total > 0 AND pages_done < pages_total", (m["url"],)
|
|
||||||
).fetchone()[0]
|
|
||||||
source_info = None
|
source_info = None
|
||||||
if m.get("source_id"):
|
if m.get("source_id"):
|
||||||
src = db.get_source_by_id(m["source_id"])
|
src = db.get_source_by_id(m["source_id"])
|
||||||
@@ -285,12 +276,12 @@ def _enrich_manga(m: dict, db: StateDB) -> dict:
|
|||||||
source_info = {"id": m["source_id"], "slug": "unknown", "display_name": "Источник недоступен"}
|
source_info = {"id": m["source_id"], "slug": "unknown", "display_name": "Источник недоступен"}
|
||||||
return {
|
return {
|
||||||
**m,
|
**m,
|
||||||
"chapters_done": ch_done_count,
|
"chapters_done": stats["done"],
|
||||||
"size_bytes": size_bytes,
|
"size_bytes": size_bytes,
|
||||||
"size_human": _format_size(size_bytes),
|
"size_human": _format_size(size_bytes),
|
||||||
"queue_position": None,
|
"queue_position": None,
|
||||||
"is_active": m["url"] in active_tasks,
|
"is_active": m["url"] in active_tasks,
|
||||||
"errors_count": ch_failed + ch_partial,
|
"errors_count": stats["failed"] + stats["partial"],
|
||||||
"started_at": m.get("started_at"),
|
"started_at": m.get("started_at"),
|
||||||
"finished_at": m.get("finished_at"),
|
"finished_at": m.get("finished_at"),
|
||||||
"source": source_info,
|
"source": source_info,
|
||||||
@@ -366,7 +357,7 @@ async def login(body: LoginRequest, response: Response):
|
|||||||
if not user or not verify_password(body.password, user["password"]):
|
if not user or not verify_password(body.password, user["password"]):
|
||||||
raise HTTPException(status_code=401, detail="Неверный логин или пароль")
|
raise HTTPException(status_code=401, detail="Неверный логин или пароль")
|
||||||
token = generate_session_token()
|
token = generate_session_token()
|
||||||
expires_at = (datetime.utcnow() + timedelta(days=30)).isoformat()
|
expires_at = (datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(days=30)).isoformat()
|
||||||
db.create_session(token, user["id"], expires_at)
|
db.create_session(token, user["id"], expires_at)
|
||||||
response.set_cookie(key=COOKIE_NAME, value=token, max_age=COOKIE_MAX_AGE,
|
response.set_cookie(key=COOKIE_NAME, value=token, max_age=COOKIE_MAX_AGE,
|
||||||
httponly=True, samesite="lax", secure=False)
|
httponly=True, samesite="lax", secure=False)
|
||||||
@@ -483,11 +474,9 @@ async def list_mangas(_: dict = Depends(get_current_user)):
|
|||||||
try:
|
try:
|
||||||
mangas = db.get_all_mangas()
|
mangas = db.get_all_mangas()
|
||||||
result = [_enrich_manga(m, db) for m in mangas]
|
result = [_enrich_manga(m, db) for m in mangas]
|
||||||
queue_list = list(download_queue._queue)
|
queue_positions = {job["url"]: i + 1 for i, job in enumerate(download_queue._queue)}
|
||||||
for i, job in enumerate(queue_list):
|
for r in result:
|
||||||
for r in result:
|
r["queue_position"] = queue_positions.get(r["url"])
|
||||||
if r["url"] == job["url"]:
|
|
||||||
r["queue_position"] = i + 1
|
|
||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -545,7 +534,6 @@ async def add_to_queue(body: AddMangaRequest, current_user: dict = Depends(get_c
|
|||||||
return {"added": added, "skipped": skipped}
|
return {"added": added, "skipped": skipped}
|
||||||
async def _fetch_preview(url: str):
|
async def _fetch_preview(url: str):
|
||||||
try:
|
try:
|
||||||
from .browser import BrowserManager
|
|
||||||
db = StateDB()
|
db = StateDB()
|
||||||
try:
|
try:
|
||||||
source = get_source_for_url(url, db)
|
source = get_source_for_url(url, db)
|
||||||
@@ -621,13 +609,7 @@ async def _check_and_queue(url: str):
|
|||||||
async def get_news(limit: int = 100, _: dict = Depends(get_current_user)):
|
async def get_news(limit: int = 100, _: dict = Depends(get_current_user)):
|
||||||
db = StateDB()
|
db = StateDB()
|
||||||
try:
|
try:
|
||||||
cur = db.conn.execute("""
|
return db.get_news(limit)
|
||||||
SELECT h.*, m.title as manga_title, m.title_ru
|
|
||||||
FROM history h LEFT JOIN mangas m ON h.manga_url = m.url
|
|
||||||
WHERE h.event_type IN ('downloaded', 'auto_downloaded')
|
|
||||||
ORDER BY h.created_at DESC LIMIT ?
|
|
||||||
""", (limit,))
|
|
||||||
return [dict(r) for r in cur.fetchall()]
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@app.get("/api/history")
|
@app.get("/api/history")
|
||||||
@@ -677,15 +659,7 @@ async def retry_errors(url: str, current_user: dict = Depends(get_current_user))
|
|||||||
if not manga:
|
if not manga:
|
||||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||||
_check_manga_access(manga, current_user)
|
_check_manga_access(manga, current_user)
|
||||||
now = db.conn.execute("SELECT datetime('now')").fetchone()[0]
|
db.reset_failed_chapters(url)
|
||||||
db.conn.execute(
|
|
||||||
"UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=?"
|
|
||||||
" WHERE manga_url=? AND status='failed'", (now, url))
|
|
||||||
db.conn.execute(
|
|
||||||
"UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=?"
|
|
||||||
" WHERE manga_url=? AND status='done' AND pages_total > 0 AND pages_done < pages_total",
|
|
||||||
(now, url))
|
|
||||||
db.conn.commit()
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -769,7 +743,7 @@ class RenameFolderRequest(BaseModel):
|
|||||||
folder_name: str
|
folder_name: str
|
||||||
@app.post("/api/mangas/rename_folder")
|
@app.post("/api/mangas/rename_folder")
|
||||||
async def rename_folder(body: RenameFolderRequest, current_user: dict = Depends(get_current_user)):
|
async def rename_folder(body: RenameFolderRequest, current_user: dict = Depends(get_current_user)):
|
||||||
new_folder = _safe_name(body.folder_name)
|
new_folder = safe_name(body.folder_name)
|
||||||
if not new_folder:
|
if not new_folder:
|
||||||
raise HTTPException(status_code=400, detail="Некорректное имя папки")
|
raise HTTPException(status_code=400, detail="Некорректное имя папки")
|
||||||
db = StateDB()
|
db = StateDB()
|
||||||
@@ -786,21 +760,9 @@ async def rename_folder(body: RenameFolderRequest, current_user: dict = Depends(
|
|||||||
if new_dir.exists():
|
if new_dir.exists():
|
||||||
raise HTTPException(status_code=400, detail=f"Папка '{new_folder}' уже существует")
|
raise HTTPException(status_code=400, detail=f"Папка '{new_folder}' уже существует")
|
||||||
if old_dir.exists():
|
if old_dir.exists():
|
||||||
import shutil
|
|
||||||
shutil.move(str(old_dir), str(new_dir))
|
shutil.move(str(old_dir), str(new_dir))
|
||||||
logger.info("Папка переименована: {} → {}", old_dir, new_dir)
|
logger.info("Папка переименована: {} → {}", old_dir, new_dir)
|
||||||
chapters = db.get_all_chapters(body.url)
|
db.update_chapter_output_paths(body.url, str(old_dir), str(new_dir))
|
||||||
for ch in chapters:
|
|
||||||
updates = {}
|
|
||||||
for col in ("output_cbz", "output_pdf", "output_epub"):
|
|
||||||
p = ch.get(col)
|
|
||||||
if p and str(old_dir) in p:
|
|
||||||
updates[col] = p.replace(str(old_dir), str(new_dir))
|
|
||||||
if updates:
|
|
||||||
sets = ", ".join(f"{k}=?" for k in updates)
|
|
||||||
db.conn.execute(f"UPDATE chapters SET {sets} WHERE chapter_url=?",
|
|
||||||
[*updates.values(), ch["chapter_url"]])
|
|
||||||
db.conn.commit()
|
|
||||||
db.set_folder_name(body.url, new_folder)
|
db.set_folder_name(body.url, new_folder)
|
||||||
await ws_manager.broadcast({"type": "manga_folder_renamed",
|
await ws_manager.broadcast({"type": "manga_folder_renamed",
|
||||||
"url": body.url, "folder_name": new_folder})
|
"url": body.url, "folder_name": new_folder})
|
||||||
@@ -816,11 +778,7 @@ async def force_redownload(url: str, _: dict = Depends(require_admin)):
|
|||||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||||
if manga["status"] == "downloading" and url in active_tasks:
|
if manga["status"] == "downloading" and url in active_tasks:
|
||||||
raise HTTPException(status_code=400, detail="Сначала остановите загрузку")
|
raise HTTPException(status_code=400, detail="Сначала остановите загрузку")
|
||||||
now = db.conn.execute("SELECT datetime('now')").fetchone()[0]
|
db.reset_all_chapters(url)
|
||||||
db.conn.execute(
|
|
||||||
"UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=? WHERE manga_url=?",
|
|
||||||
(now, url))
|
|
||||||
db.conn.commit()
|
|
||||||
db.update_manga_status(url, "queued")
|
db.update_manga_status(url, "queued")
|
||||||
await download_queue.put({"url": url, "fmt": manga["format"], "resume": False})
|
await download_queue.put({"url": url, "fmt": manga["format"], "resume": False})
|
||||||
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]})
|
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]})
|
||||||
@@ -876,13 +834,9 @@ async def delete_manga(url: str, delete_files: bool = False, _: dict = Depends(r
|
|||||||
manga_dir = _manga_folder(manga)
|
manga_dir = _manga_folder(manga)
|
||||||
if manga_dir.exists() and manga_dir.is_dir():
|
if manga_dir.exists() and manga_dir.is_dir():
|
||||||
deleted_size = _dir_size(manga_dir)
|
deleted_size = _dir_size(manga_dir)
|
||||||
import shutil
|
|
||||||
shutil.rmtree(str(manga_dir))
|
shutil.rmtree(str(manga_dir))
|
||||||
logger.info("Удалена папка: {} ({} байт)", manga_dir, deleted_size)
|
logger.info("Удалена папка: {} ({} байт)", manga_dir, deleted_size)
|
||||||
db.conn.execute("DELETE FROM chapters WHERE manga_url=?", (url,))
|
db.delete_manga_cascade(url)
|
||||||
db.conn.execute("DELETE FROM history WHERE manga_url=?", (url,))
|
|
||||||
db.conn.execute("DELETE FROM mangas WHERE url=?", (url,))
|
|
||||||
db.conn.commit()
|
|
||||||
return {"ok": True, "deleted_size": deleted_size}
|
return {"ok": True, "deleted_size": deleted_size}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -95,32 +95,6 @@ class BrowserManager:
|
|||||||
page = await ctx.new_page()
|
page = await ctx.new_page()
|
||||||
return ctx, page
|
return ctx, page
|
||||||
|
|
||||||
async def navigate(self, page: Page, url: str, timeout: int = 60_000,
|
|
||||||
referer: str | None = None) -> bool:
|
|
||||||
"""
|
|
||||||
Открывает URL и ждёт загрузки.
|
|
||||||
referer — явно выставляется в заголовке запроса (обход защиты сервера).
|
|
||||||
Возвращает True при успехе.
|
|
||||||
"""
|
|
||||||
# Если referer не передан явно — берём домен из url
|
|
||||||
if referer is None:
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
p = urlparse(url)
|
|
||||||
referer = f"{p.scheme}://{p.netloc}/"
|
|
||||||
try:
|
|
||||||
logger.debug("Навигация: {} (referer={})", url, referer)
|
|
||||||
response = await page.goto(url, wait_until="domcontentloaded",
|
|
||||||
timeout=timeout, referer=referer)
|
|
||||||
if response and response.status >= 400:
|
|
||||||
logger.warning("HTTP {}: {}", response.status, url)
|
|
||||||
return False
|
|
||||||
# Ждём завершения JS
|
|
||||||
await page.wait_for_load_state("networkidle", timeout=timeout)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Ошибка навигации {}: {}", url, e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
await self.start()
|
await self.start()
|
||||||
return self
|
return self
|
||||||
|
|||||||
111
src/cli.py
111
src/cli.py
@@ -16,9 +16,11 @@ from loguru import logger
|
|||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
from .browser import BrowserManager
|
from .browser import BrowserManager
|
||||||
from .scraper import get_manga_info, get_chapter_images_and_download, Chapter
|
from .sources import registry, get_source_for_url
|
||||||
from .exporter import export, ExportFormat
|
from .sources.base import Chapter
|
||||||
|
from .exporter import export, ExportFormat, MangaMeta
|
||||||
from .state import StateDB
|
from .state import StateDB
|
||||||
|
from .utils import safe_name, safe_chapter_name
|
||||||
|
|
||||||
OUTPUT_DIR = Path("/app/output")
|
OUTPUT_DIR = Path("/app/output")
|
||||||
STATE_DIR = Path("/app/state")
|
STATE_DIR = Path("/app/state")
|
||||||
@@ -80,36 +82,41 @@ def download(ctx, url, fmt, chapters, output, resume, force, concurrency):
|
|||||||
|
|
||||||
async def _download(url, fmt, chapters_filter, output_dir, resume, force, concurrency, verbose):
|
async def _download(url, fmt, chapters_filter, output_dir, resume, force, concurrency, verbose):
|
||||||
db = StateDB()
|
db = StateDB()
|
||||||
|
db.sync_sources(registry)
|
||||||
|
|
||||||
|
source = get_source_for_url(url, db)
|
||||||
|
if source is None:
|
||||||
|
srcs = registry.all_sources()
|
||||||
|
source = srcs[0] if srcs else None
|
||||||
|
if source is None:
|
||||||
|
logger.error("Источник не определён для URL: {}", url)
|
||||||
|
db.close()
|
||||||
|
return
|
||||||
|
|
||||||
async with BrowserManager(headless=True) as bm:
|
async with BrowserManager(headless=True) as bm:
|
||||||
ctx, page = await bm.new_page()
|
ctx, page = await bm.new_page()
|
||||||
|
|
||||||
# 1. Получаем список глав
|
manga = await source.get_manga_info(page, url)
|
||||||
manga = await get_manga_info(page, url)
|
|
||||||
if not manga:
|
if not manga:
|
||||||
logger.error("Не удалось получить информацию о манге")
|
logger.error("Не удалось получить информацию о манге")
|
||||||
|
db.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
manga_dir = output_dir / _safe_name(manga.title)
|
manga_dir = output_dir / safe_name(manga.title_ru or manga.title)
|
||||||
manga_dir.mkdir(parents=True, exist_ok=True)
|
manga_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 2. Сохраняем все главы в БД
|
|
||||||
for ch in manga.chapters:
|
for ch in manga.chapters:
|
||||||
db.upsert_chapter(url, ch.url, ch.title, ch.number, ch.volume)
|
db.upsert_chapter(url, ch.url, ch.title, ch.number, ch.volume)
|
||||||
|
|
||||||
# 3. Фильтрация
|
|
||||||
chapters = _filter_chapters(manga.chapters, chapters_filter)
|
chapters = _filter_chapters(manga.chapters, chapters_filter)
|
||||||
logger.info("Будет скачано глав: {}", len(chapters))
|
logger.info("Будет скачано глав: {}", len(chapters))
|
||||||
|
|
||||||
# 4. Форматы
|
|
||||||
formats: list[ExportFormat] = ["cbz", "pdf", "epub"] if fmt == "all" else [fmt]
|
formats: list[ExportFormat] = ["cbz", "pdf", "epub"] if fmt == "all" else [fmt]
|
||||||
|
|
||||||
# 5. Скачиваем каждую главу
|
|
||||||
with tqdm(total=len(chapters), desc="Главы", unit="гл") as pbar:
|
with tqdm(total=len(chapters), desc="Главы", unit="гл") as pbar:
|
||||||
for ch in chapters:
|
for ch in chapters:
|
||||||
pbar.set_description(f"Глава {ch.number}: {ch.title[:30]}")
|
pbar.set_description(f"Глава {ch.number}: {ch.title[:30]}")
|
||||||
|
|
||||||
# Проверяем статус (resume / force)
|
|
||||||
if force:
|
if force:
|
||||||
db.reset_chapter(ch.url)
|
db.reset_chapter(ch.url)
|
||||||
elif resume and db.chapter_status(ch.url) == "done":
|
elif resume and db.chapter_status(ch.url) == "done":
|
||||||
@@ -118,10 +125,10 @@ async def _download(url, fmt, chapters_filter, output_dir, resume, force, concur
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
await _process_chapter(
|
await _process_chapter(
|
||||||
bm=bm, ctx=ctx, ch=ch,
|
source=source, ctx=ctx, ch=ch,
|
||||||
manga_url=url,
|
manga=manga, manga_url=url,
|
||||||
manga_dir=manga_dir, formats=formats,
|
manga_dir=manga_dir, formats=formats,
|
||||||
concurrency=concurrency, db=db, force=force,
|
db=db, force=force,
|
||||||
)
|
)
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
|
|
||||||
@@ -130,16 +137,14 @@ async def _download(url, fmt, chapters_filter, output_dir, resume, force, concur
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
async def _process_chapter(bm, ctx, ch: Chapter, manga_url: str, manga_dir: Path,
|
async def _process_chapter(source, ctx, ch: Chapter, manga, manga_url: str,
|
||||||
formats: list, concurrency: int, db: StateDB, force: bool = False):
|
manga_dir: Path, formats: list, db: StateDB, force: bool = False):
|
||||||
# Новая страница для каждой главы (чистый контекст)
|
|
||||||
ch_page = await ctx.new_page()
|
ch_page = await ctx.new_page()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
tmp_path = Path(tmpdir)
|
tmp_path = Path(tmpdir)
|
||||||
# Открываем главу и скачиваем изображения за один проход
|
image_paths = await source.get_chapter_images_and_download(
|
||||||
image_paths = await get_chapter_images_and_download(
|
|
||||||
ch_page, ch.url, dest_dir=tmp_path, manga_url=manga_url
|
ch_page, ch.url, dest_dir=tmp_path, manga_url=manga_url
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,16 +153,27 @@ async def _process_chapter(bm, ctx, ch: Chapter, manga_url: str, manga_dir: Path
|
|||||||
db.mark_failed(ch.url)
|
db.mark_failed(ch.url)
|
||||||
return
|
return
|
||||||
|
|
||||||
ch_name = _safe_chapter_name(ch)
|
ch_name = safe_chapter_name(ch)
|
||||||
|
ch_meta = MangaMeta(
|
||||||
|
series=manga.title_ru or manga.title,
|
||||||
|
series_full=manga.title_full or "",
|
||||||
|
chapter_title=ch.title,
|
||||||
|
number=ch.number,
|
||||||
|
volume=ch.volume,
|
||||||
|
chapters_total=len(manga.chapters),
|
||||||
|
pub_status=manga.pub_status,
|
||||||
|
source_url=manga_url,
|
||||||
|
summary=manga.description,
|
||||||
|
genre=", ".join(manga.genres) if manga.genres else "",
|
||||||
|
)
|
||||||
|
|
||||||
for fmt in formats:
|
for fmt in formats:
|
||||||
out_file = manga_dir / f"{ch_name}.{fmt}"
|
out_file = manga_dir / f"{ch_name}.{fmt}"
|
||||||
# При --force удаляем старый файл перед перезаписью
|
|
||||||
if force and out_file.exists():
|
if force and out_file.exists():
|
||||||
out_file.unlink()
|
out_file.unlink()
|
||||||
logger.debug("Удалён старый файл: {}", out_file.name)
|
logger.debug("Удалён старый файл: {}", out_file.name)
|
||||||
try:
|
try:
|
||||||
export(image_paths, out_file, fmt, manga_dir.name, ch.title)
|
export(image_paths, out_file, fmt, meta=ch_meta)
|
||||||
db.mark_done(ch.url, fmt, str(out_file))
|
db.mark_done(ch.url, fmt, str(out_file))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка экспорта {}: {}", fmt, e)
|
logger.error("Ошибка экспорта {}: {}", fmt, e)
|
||||||
@@ -180,15 +196,28 @@ def analyze(ctx, url):
|
|||||||
|
|
||||||
|
|
||||||
async def _analyze(url: str):
|
async def _analyze(url: str):
|
||||||
|
db = StateDB()
|
||||||
|
db.sync_sources(registry)
|
||||||
|
|
||||||
|
source = get_source_for_url(url, db)
|
||||||
|
if source is None:
|
||||||
|
srcs = registry.all_sources()
|
||||||
|
source = srcs[0] if srcs else None
|
||||||
|
if source is None:
|
||||||
|
click.echo("❌ Источник не найден")
|
||||||
|
db.close()
|
||||||
|
return
|
||||||
|
|
||||||
async with BrowserManager(headless=True) as bm:
|
async with BrowserManager(headless=True) as bm:
|
||||||
_, page = await bm.new_page()
|
_, page = await bm.new_page()
|
||||||
manga = await get_manga_info(page, url)
|
manga = await source.get_manga_info(page, url)
|
||||||
|
|
||||||
if not manga:
|
if not manga:
|
||||||
click.echo("❌ Не удалось получить информацию")
|
click.echo("❌ Не удалось получить информацию")
|
||||||
|
db.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
click.echo(f"\n📚 Манга: {manga.title}")
|
click.echo(f"\n📚 Манга: {manga.title_ru or manga.title}")
|
||||||
click.echo(f"🔗 URL: {manga.url}")
|
click.echo(f"🔗 URL: {manga.url}")
|
||||||
click.echo(f"📖 Глав: {len(manga.chapters)}\n")
|
click.echo(f"📖 Глав: {len(manga.chapters)}\n")
|
||||||
|
|
||||||
@@ -198,64 +227,34 @@ async def _analyze(url: str):
|
|||||||
if len(manga.chapters) > 20:
|
if len(manga.chapters) > 20:
|
||||||
click.echo(f" ... и ещё {len(manga.chapters) - 20} глав")
|
click.echo(f" ... и ещё {len(manga.chapters) - 20} глав")
|
||||||
|
|
||||||
# Проверяем одну главу
|
|
||||||
if manga.chapters:
|
if manga.chapters:
|
||||||
first = manga.chapters[-1]
|
first = manga.chapters[-1]
|
||||||
click.echo(f"\n🔍 Проверяем первую главу: {first.url}")
|
click.echo(f"\n🔍 Проверяем первую главу: {first.url}")
|
||||||
import tempfile
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
paths = await get_chapter_images_and_download(
|
paths = await source.get_chapter_images_and_download(
|
||||||
page, first.url, dest_dir=Path(tmp), manga_url=url
|
page, first.url, dest_dir=Path(tmp), manga_url=url
|
||||||
)
|
)
|
||||||
click.echo(f" Скачано изображений: {len(paths)}")
|
click.echo(f" Скачано изображений: {len(paths)}")
|
||||||
for p in paths[:3]:
|
for p in paths[:3]:
|
||||||
click.echo(f" {p.name} ({p.stat().st_size} байт)")
|
click.echo(f" {p.name} ({p.stat().st_size} байт)")
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
# ── Утилиты ───────────────────────────────────
|
# ── Утилиты ───────────────────────────────────
|
||||||
|
|
||||||
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}"
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_chapters(chapters: list[Chapter], filter_str: str | None) -> list[Chapter]:
|
def _filter_chapters(chapters: list[Chapter], filter_str: str | None) -> list[Chapter]:
|
||||||
if not filter_str:
|
if not filter_str:
|
||||||
return chapters
|
return chapters
|
||||||
|
|
||||||
# "1-10" → диапазон
|
|
||||||
m = re.match(r"^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$", filter_str)
|
m = re.match(r"^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$", filter_str)
|
||||||
if m:
|
if m:
|
||||||
lo, hi = float(m.group(1)), float(m.group(2))
|
lo, hi = float(m.group(1)), float(m.group(2))
|
||||||
return [c for c in chapters if lo <= c.number <= hi]
|
return [c for c in chapters if lo <= c.number <= hi]
|
||||||
|
|
||||||
# "1,3,7" → список
|
|
||||||
nums = {float(x.strip()) for x in filter_str.split(",")}
|
nums = {float(x.strip()) for x in filter_str.split(",")}
|
||||||
return [c for c in chapters if c.number in nums]
|
return [c for c in chapters if c.number in nums]
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli()
|
cli()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -131,8 +131,12 @@ def _export_pdf(images: list[Path], out: Path, meta: MangaMeta):
|
|||||||
def _export_pdf_pillow(images: list[Path], out: Path):
|
def _export_pdf_pillow(images: list[Path], out: Path):
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
pil_images = [Image.open(p).convert("RGB") for p in images]
|
pil_images = [Image.open(p).convert("RGB") for p in images]
|
||||||
if pil_images:
|
try:
|
||||||
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
|
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()
|
||||||
|
|
||||||
|
|
||||||
def _patch_pdf_meta(pdf_path: Path, meta: MangaMeta):
|
def _patch_pdf_meta(pdf_path: Path, meta: MangaMeta):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import re
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from playwright.async_api import Page
|
from playwright.async_api import Page
|
||||||
@@ -88,7 +89,6 @@ class ReadmangaSource:
|
|||||||
ch_id = chapter_url.split("/")[-1]
|
ch_id = chapter_url.split("/")[-1]
|
||||||
logger.info("[{}] Загружаем главу: {}", ch_id, chapter_url)
|
logger.info("[{}] Загружаем главу: {}", ch_id, chapter_url)
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
parsed = urlparse(chapter_url)
|
parsed = urlparse(chapter_url)
|
||||||
parts = parsed.path.strip("/").split("/")
|
parts = parsed.path.strip("/").split("/")
|
||||||
manga_slug = parts[0] if parts else ""
|
manga_slug = parts[0] if parts else ""
|
||||||
@@ -277,8 +277,6 @@ class ReadmangaSource:
|
|||||||
elapsed = time.monotonic() - t_start
|
elapsed = time.monotonic() - t_start
|
||||||
logger.info("[{}] Перехвачено: {}/{} за {:.1f}с", ch_id, done, total, elapsed)
|
logger.info("[{}] Перехвачено: {}/{} за {:.1f}с", ch_id, done, total, elapsed)
|
||||||
|
|
||||||
filename_to_idx = {_base(u).split("/")[-1]: i for i, u in enumerate(image_urls)}
|
|
||||||
|
|
||||||
paths: dict[int, Path] = {}
|
paths: dict[int, Path] = {}
|
||||||
unmatched_other: list[str] = []
|
unmatched_other: list[str] = []
|
||||||
for base_url, body in captured.items():
|
for base_url, body in captured.items():
|
||||||
@@ -337,7 +335,6 @@ class ReadmangaSource:
|
|||||||
|
|
||||||
async def _navigate(page: Page, url: str, retries: int = 3,
|
async def _navigate(page: Page, url: str, retries: int = 3,
|
||||||
referer: str | None = None) -> bool:
|
referer: str | None = None) -> bool:
|
||||||
from urllib.parse import urlparse
|
|
||||||
if referer is None:
|
if referer is None:
|
||||||
p = urlparse(url)
|
p = urlparse(url)
|
||||||
referer = f"{p.scheme}://{p.netloc}/"
|
referer = f"{p.scheme}://{p.netloc}/"
|
||||||
|
|||||||
64
src/state.py
64
src/state.py
@@ -3,7 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -402,10 +402,6 @@ class StateDB:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def increment_manga_chapters_done(self, url: str):
|
|
||||||
# Оставлен для совместимости, но не используется в воркере
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_manga(self, url: str) -> Optional[dict]:
|
def get_manga(self, url: str) -> Optional[dict]:
|
||||||
cur = self.conn.execute("""
|
cur = self.conn.execute("""
|
||||||
SELECT m.*, u.username AS added_by_username
|
SELECT m.*, u.username AS added_by_username
|
||||||
@@ -428,6 +424,57 @@ class StateDB:
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return row["format"] if row else "cbz"
|
return row["format"] if row else "cbz"
|
||||||
|
|
||||||
|
def get_chapter_stats(self, manga_url: str) -> dict:
|
||||||
|
"""Returns done/failed/partial chapter counts in a single query."""
|
||||||
|
row = self.conn.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN status='done' THEN 1 END) as done,
|
||||||
|
COUNT(CASE WHEN status='failed' THEN 1 END) as failed,
|
||||||
|
COUNT(CASE WHEN status='done' AND pages_total > 0
|
||||||
|
AND pages_done < pages_total THEN 1 END) as partial
|
||||||
|
FROM chapters WHERE manga_url=?
|
||||||
|
""", (manga_url,)).fetchone()
|
||||||
|
return {"done": row[0], "failed": row[1], "partial": row[2]}
|
||||||
|
|
||||||
|
def reset_all_chapters(self, manga_url: str) -> None:
|
||||||
|
"""Resets ALL chapters to pending (used by force-redownload)."""
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=?"
|
||||||
|
" WHERE manga_url=?",
|
||||||
|
(_now(), manga_url)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def delete_manga_cascade(self, manga_url: str) -> None:
|
||||||
|
"""Deletes manga and all related chapters and history."""
|
||||||
|
self.conn.execute("DELETE FROM chapters WHERE manga_url=?", (manga_url,))
|
||||||
|
self.conn.execute("DELETE FROM history WHERE manga_url=?", (manga_url,))
|
||||||
|
self.conn.execute("DELETE FROM mangas WHERE url=?", (manga_url,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def update_chapter_output_paths(self, manga_url: str, old_prefix: str, new_prefix: str) -> None:
|
||||||
|
"""Replaces old_prefix with new_prefix in chapter output paths after folder rename."""
|
||||||
|
chapters = self.get_all_chapters(manga_url)
|
||||||
|
for ch in chapters:
|
||||||
|
for col in ("output_cbz", "output_pdf", "output_epub"):
|
||||||
|
p = ch.get(col)
|
||||||
|
if p and old_prefix in p:
|
||||||
|
self.conn.execute(
|
||||||
|
f"UPDATE chapters SET {col}=?, updated_at=? WHERE chapter_url=?",
|
||||||
|
(p.replace(old_prefix, new_prefix), _now(), ch["chapter_url"])
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_news(self, limit: int = 100) -> list[dict]:
|
||||||
|
"""Returns recently downloaded chapters for the news feed."""
|
||||||
|
cur = self.conn.execute("""
|
||||||
|
SELECT h.*, m.title as manga_title, m.title_ru
|
||||||
|
FROM history h LEFT JOIN mangas m ON h.manga_url = m.url
|
||||||
|
WHERE h.event_type IN ('downloaded', 'auto_downloaded')
|
||||||
|
ORDER BY h.created_at DESC LIMIT ?
|
||||||
|
""", (limit,))
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
# ── Chapters ──────────────────────────────────
|
# ── Chapters ──────────────────────────────────
|
||||||
|
|
||||||
def upsert_chapter(self, manga_url: str, chapter_url: str,
|
def upsert_chapter(self, manga_url: str, chapter_url: str,
|
||||||
@@ -451,6 +498,8 @@ class StateDB:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
|
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}"
|
col = f"output_{fmt}"
|
||||||
self.conn.execute(f"""
|
self.conn.execute(f"""
|
||||||
UPDATE chapters SET status='done', {col}=?, updated_at=?
|
UPDATE chapters SET status='done', {col}=?, updated_at=?
|
||||||
@@ -624,8 +673,11 @@ class StateDB:
|
|||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_FMTS = frozenset({"cbz", "pdf", "epub"})
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
return datetime.utcnow().isoformat()
|
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def _extract_domain(url: str) -> str:
|
def _extract_domain(url: str) -> str:
|
||||||
|
|||||||
15
src/utils.py
Normal file
15
src/utils.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
Общие утилиты, используемые в нескольких модулях.
|
||||||
|
"""
|
||||||
|
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}"
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
@@ -13,9 +12,9 @@ from loguru import logger
|
|||||||
from .browser import BrowserManager
|
from .browser import BrowserManager
|
||||||
from .sources import registry, get_source_for_url, extract_domain
|
from .sources import registry, get_source_for_url, extract_domain
|
||||||
from .sources.base import Chapter, MangaInfo
|
from .sources.base import Chapter, MangaInfo
|
||||||
from .scraper import get_manga_info, get_chapter_images_and_download # shim для обратной совместимости
|
|
||||||
from .exporter import export, MangaMeta
|
from .exporter import export, MangaMeta
|
||||||
from .state import StateDB
|
from .state import StateDB
|
||||||
|
from .utils import safe_name, safe_chapter_name
|
||||||
|
|
||||||
OUTPUT_DIR = Path("/app/output")
|
OUTPUT_DIR = Path("/app/output")
|
||||||
|
|
||||||
@@ -23,15 +22,6 @@ OUTPUT_DIR = Path("/app/output")
|
|||||||
CHAPTER_CONCURRENCY = int(os.getenv("CHAPTER_CONCURRENCY", "3"))
|
CHAPTER_CONCURRENCY = int(os.getenv("CHAPTER_CONCURRENCY", "3"))
|
||||||
|
|
||||||
|
|
||||||
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}"
|
|
||||||
|
|
||||||
|
|
||||||
async def download_manga(
|
async def download_manga(
|
||||||
url: str,
|
url: str,
|
||||||
fmt: str = "cbz",
|
fmt: str = "cbz",
|
||||||
@@ -111,7 +101,7 @@ async def download_manga(
|
|||||||
_db_manga = await db_call(db.get_manga, url)
|
_db_manga = await db_call(db.get_manga, url)
|
||||||
folder_name = (
|
folder_name = (
|
||||||
(_db_manga.get("folder_name") if _db_manga else None)
|
(_db_manga.get("folder_name") if _db_manga else None)
|
||||||
or _safe_name(manga.title_ru or manga.title)
|
or safe_name(manga.title_ru or manga.title)
|
||||||
)
|
)
|
||||||
manga_dir = output_dir / folder_name
|
manga_dir = output_dir / folder_name
|
||||||
manga_dir.mkdir(parents=True, exist_ok=True)
|
manga_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -193,18 +183,19 @@ async def download_manga(
|
|||||||
try:
|
try:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
tmp_path = Path(tmpdir)
|
tmp_path = Path(tmpdir)
|
||||||
pages_done_count = [0]
|
pages_done = 0
|
||||||
|
|
||||||
async def on_page(page_idx: int, pages_total: int):
|
async def on_page(page_idx: int, pages_total: int):
|
||||||
pages_done_count[0] += 1
|
nonlocal pages_done
|
||||||
|
pages_done += 1
|
||||||
await db_call(db.update_chapter_pages,
|
await db_call(db.update_chapter_pages,
|
||||||
ch.url, pages_total, pages_done_count[0])
|
ch.url, pages_total, pages_done)
|
||||||
await emit({
|
await emit({
|
||||||
"type": "page_done",
|
"type": "page_done",
|
||||||
"url": url,
|
"url": url,
|
||||||
"chapter_url": ch.url,
|
"chapter_url": ch.url,
|
||||||
"page_idx": page_idx,
|
"page_idx": page_idx,
|
||||||
"pages_done": pages_done_count[0],
|
"pages_done": pages_done,
|
||||||
"pages_total": pages_total,
|
"pages_total": pages_total,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -226,7 +217,7 @@ async def download_manga(
|
|||||||
"chapter_url": ch.url})
|
"chapter_url": ch.url})
|
||||||
return
|
return
|
||||||
|
|
||||||
ch_name = _safe_chapter_name(ch)
|
ch_name = safe_chapter_name(ch)
|
||||||
ch_meta = MangaMeta(
|
ch_meta = MangaMeta(
|
||||||
series=manga.title_ru or manga.title,
|
series=manga.title_ru or manga.title,
|
||||||
series_full=manga.title_full or "",
|
series_full=manga.title_full or "",
|
||||||
|
|||||||
Reference in New Issue
Block a user