Files
manga/CODE_REVIEW.md
2026-05-01 03:50:25 +03:00

15 KiB
Raw Blame History

Code Review: находки и предложения

Статус: только чтение — ничего не изменено. Каждый пункт сопровождается предлагаемым исправлением.


1. Дублирование кода — _safe_name / _safe_chapter_name

Файлы: src/api.py:251, src/worker.py:2632, src/cli.py (аналогичные функции)

Одна и та же функция определена в трёх местах. При изменении логики нужно менять сразу в трёх файлах — риск расхождения.

Исправление: вынести в src/utils.py, импортировать везде:

# 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:

# 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. Используется в двух местах.

Исправление:

# 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

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.

Исправление:

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

def increment_manga_chapters_done(self, url: str):
    # Оставлен для совместимости, но не используется в воркере
    pass

Метод ничего не делает и нигде не вызывается.

Исправление: удалить.


7. Отложенный импорт BrowserManager в _fetch_preview

Файл: src/api.py:548

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

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.

Исправление:

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

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

Исправление:

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

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, это станет инъекцией.

Исправление:

_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

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

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() вернёт ошибку или пустой объект.

Исправление:

const data = await r.json();
if (!r.ok) {
    showError(data.detail || 'Ошибка');
    return;
}

14. escHtml() не защищает от JS-инъекции в onclick

Файл: frontend/index.html — различные места типа:

`<button onclick="openEditUserModal(${u.id}, '${escHtml(u.username)}', ...)">

escHtml экранирует HTML-спецсимволы (<, >, &), но не защищает от инъекции в контекст JavaScript-строки. Если u.username содержит '); evil() // — экранирование не поможет.

Исправление: вместо onclick="..." в строке шаблона использовать data-* атрибуты + делегирование событий:

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

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

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 На будущее