This commit is contained in:
2026-05-01 03:50:25 +03:00
parent 43597be020
commit bc7b5bfe37
10 changed files with 549 additions and 185 deletions

369
CODE_REVIEW.md Normal file
View File

@@ -0,0 +1,369 @@
# Code Review: находки и предложения
> Статус: **только чтение** — ничего не изменено. Каждый пункт сопровождается предлагаемым исправлением.
---
## 1. Дублирование кода — `_safe_name` / `_safe_chapter_name`
**Файлы:** `src/api.py:251`, `src/worker.py:2632`, `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` | 269278 |
| `retry_errors` | 680688 |
| `force_redownload` | 819823 |
| `delete_manga` | 882885 |
| `rename_folder` | 801803 |
`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:343400`
`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:405407`
```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:486491`
```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:131135`
```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:453459`
```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 | На будущее |

View File

@@ -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;
} }

View File

@@ -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:
if r["url"] == job["url"]: r["queue_position"] = queue_positions.get(r["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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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]
try:
if pil_images: if pil_images:
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF") 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):

View File

@@ -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}/"

View File

@@ -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
View 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}"

View File

@@ -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 "",