Compare commits
3 Commits
validation
...
6c0958b92e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c0958b92e | |||
| 93eff68b8d | |||
| ebc1825794 |
@@ -19,4 +19,4 @@ VOLUME ["/app/output", "/app/state"]
|
||||
|
||||
# По умолчанию запускаем веб-сервер
|
||||
ENTRYPOINT []
|
||||
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
# План реализации: Multi-Source архитектура
|
||||
|
||||
Рефакторинг под систему плагинов-адаптеров: каждый источник — отдельный класс с унифицированным `Protocol`-интерфейсом. Новые таблицы `sources` / `source_domains` в БД, автоопределение источника по домену URL, CRUD-API для доменов и UI-компоненты во фронтенде. Существующий `scraper.py` становится адаптером `ReadmangaSource`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Архитектура системы источников
|
||||
|
||||
**Организация**: `Protocol`-интерфейс + реестр (`SourceRegistry`) + slug-имена в коде.
|
||||
|
||||
Создать `src/sources/` — пакет с адаптерами:
|
||||
|
||||
```
|
||||
src/sources/
|
||||
__init__.py ← реестр + фабрика
|
||||
base.py ← MangaSourceProtocol (Protocol-класс)
|
||||
readmanga.py ← ReadmangaSource (перенесённый scraper.py)
|
||||
```
|
||||
|
||||
### `base.py` — Protocol-интерфейс
|
||||
|
||||
```python
|
||||
class MangaSourceProtocol(Protocol):
|
||||
slug: str # "readmanga" — уникальный код в коде
|
||||
display_name: str # "ReadManga" — для UI
|
||||
|
||||
async def get_manga_info(self, page, url) -> Optional[MangaInfo]: ...
|
||||
async def get_chapter_images_and_download(
|
||||
self, page, chapter_url, dest_dir, ...
|
||||
) -> list[Path]: ...
|
||||
```
|
||||
|
||||
### `__init__.py` — реестр и резолвинг
|
||||
|
||||
`SourceRegistry` — dict `slug → instance`. Список источников **определяется только в коде** — новый источник добавляется созданием нового класса и регистрацией в реестре. Через API управлять можно **только доменами**.
|
||||
|
||||
Экспортирует:
|
||||
|
||||
- `registry.get_by_slug(slug)` — по коду источника
|
||||
- `registry.get_by_id(source_id, db)` — через БД: `sources.id → slug → экземпляр`
|
||||
- `registry.all()` — полный список зарегистрированных источников (для синхронизации с БД и отображения в UI)
|
||||
- `get_source_for_url(url, db)` — извлекает домен из URL, ищет в `source_domains`, возвращает адаптер или `None` (домен неизвестен)
|
||||
|
||||
### `readmanga.py` — `ReadmangaSource`
|
||||
|
||||
Класс с `slug = "readmanga"`. Весь текущий код `scraper.py` переезжает сюда без изменений. CDN-фильтр вынесен в атрибут `cdn_patterns: list[str]`, который можно переопределить настройками из `sources.settings` (JSON). Адаптер самодостаточен.
|
||||
|
||||
### Добавление нового источника
|
||||
|
||||
Создать файл `src/sources/mysource.py`, реализовать Protocol, зарегистрировать:
|
||||
|
||||
```python
|
||||
# src/sources/__init__.py
|
||||
from .readmanga import ReadmangaSource
|
||||
from .mysource import MySource
|
||||
|
||||
registry = SourceRegistry([
|
||||
ReadmangaSource(),
|
||||
MySource(),
|
||||
])
|
||||
```
|
||||
|
||||
При следующем старте приложения `StateDB._sync_sources()` автоматически добавит запись нового источника в таблицу `sources` (если её ещё нет). Удалять источники из кода не рекомендуется без предварительной миграции манг.
|
||||
|
||||
---
|
||||
|
||||
## 2. Изменения БД
|
||||
|
||||
### Новые таблицы
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT UNIQUE NOT NULL, -- "readmanga" — совпадает с кодом
|
||||
display_name TEXT NOT NULL,
|
||||
settings TEXT DEFAULT '{}', -- JSON: cdn_patterns и др.
|
||||
created_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS source_domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_id INTEGER NOT NULL REFERENCES sources(id),
|
||||
domain TEXT UNIQUE NOT NULL -- "readmanga.ru", "readmanga.live"
|
||||
);
|
||||
```
|
||||
|
||||
### Изменение таблицы `mangas`
|
||||
|
||||
```sql
|
||||
ALTER TABLE mangas ADD COLUMN source_id INTEGER REFERENCES sources(id);
|
||||
```
|
||||
|
||||
Добавляется через существующий паттерн миграций в `StateDB._init()`.
|
||||
|
||||
### Синхронизация источников с кодом (`_sync_sources`)
|
||||
|
||||
При старте (в `_init()`) вызывается `_sync_sources(registry)`:
|
||||
1. Для каждого источника из реестра — вставить запись в `sources` если ещё нет (по `slug`).
|
||||
2. Обновить `display_name` если изменился.
|
||||
3. **Не удалять** источники из БД даже если они убраны из реестра — только логировать предупреждение.
|
||||
|
||||
### Авто-миграция существующих манг
|
||||
|
||||
При старте пройтись по всем мангам с `source_id IS NULL`, определить домен из `url`, проставить `source_id` по совпадению в `source_domains`. Если домен не найден — оставить `NULL` (отобразится в UI как «источник не определён»).
|
||||
|
||||
### Сидинг доменов ReadManga
|
||||
|
||||
```python
|
||||
DEFAULT_READMANGA_DOMAINS = [
|
||||
"readmanga.ru", "readmanga.live", "readmanga.me", "readmanga.io",
|
||||
"3.readmanga.ru",
|
||||
]
|
||||
```
|
||||
|
||||
Вставляется однократно при первом старте (если нет ни одного домена для `readmanga`).
|
||||
|
||||
### Новые методы `StateDB`
|
||||
|
||||
- `get_source_by_domain(domain)` → `dict | None`
|
||||
- `get_all_sources()` → `list[dict]` (с вложенными доменами)
|
||||
- `add_domain(source_id, domain)` → `bool`
|
||||
- `remove_domain(source_id, domain)`
|
||||
- `set_manga_source(manga_url, source_id)` — меняет источник + привязывает домен URL к новому источнику (см. §3)
|
||||
|
||||
---
|
||||
|
||||
## 3. Рефакторинг `scraper.py` и `worker.py`
|
||||
|
||||
### `src/scraper.py` — shim для обратной совместимости
|
||||
|
||||
После переноса кода в `ReadmangaSource`:
|
||||
|
||||
```python
|
||||
# src/scraper.py
|
||||
from .sources.readmanga import ReadmangaSource as _src
|
||||
from .sources.base import MangaInfo, Chapter
|
||||
|
||||
_instance = _src()
|
||||
|
||||
async def get_manga_info(page, url):
|
||||
return await _instance.get_manga_info(page, url)
|
||||
|
||||
async def get_chapter_images_and_download(page, chapter_url, dest_dir, **kw):
|
||||
return await _instance.get_chapter_images_and_download(page, chapter_url, dest_dir, **kw)
|
||||
```
|
||||
|
||||
Это позволяет не ломать `worker.py` и `cli.py` на переходном этапе.
|
||||
|
||||
### `src/worker.py` — подключение реестра
|
||||
|
||||
В `download_manga(url, fmt, ...)`:
|
||||
|
||||
```python
|
||||
from .sources import get_source_for_url
|
||||
|
||||
source = get_source_for_url(url, db)
|
||||
if source is None:
|
||||
# Источник не определён — ошибка, уведомить через WS
|
||||
await ws_broadcast({"type": "source_unknown", "url": url})
|
||||
return
|
||||
```
|
||||
|
||||
Передавать `source` в `process_chapter()` и далее в функции скачивания.
|
||||
|
||||
`check_for_updates()` — аналогично резолвит источник.
|
||||
|
||||
### Смена источника + перепривязка домена
|
||||
|
||||
```python
|
||||
async def switch_source(manga_url: str, new_source_id: int, db: StateDB):
|
||||
"""Меняет источник манги и привязывает домен URL к новому источнику."""
|
||||
domain = extract_domain(manga_url) # извлечь домен из URL манги
|
||||
old_domain_source = db.get_source_by_domain(domain)
|
||||
|
||||
# Перепривязать домен к новому источнику
|
||||
if old_domain_source:
|
||||
db.remove_domain(old_domain_source["id"], domain)
|
||||
db.add_domain(new_source_id, domain)
|
||||
|
||||
# Сменить источник у манги
|
||||
db.set_manga_source(manga_url, new_source_id)
|
||||
|
||||
# Сбросить failed/partial главы → pending
|
||||
db.reset_failed_chapters(manga_url)
|
||||
```
|
||||
|
||||
Таким образом, при следующем добавлении манги с того же домена источник будет определён автоматически правильно.
|
||||
|
||||
---
|
||||
|
||||
## 4. API эндпоинты
|
||||
|
||||
**Создание и удаление источников через API недоступны** — источники определяются только в коде.
|
||||
|
||||
### Источники (только чтение + управление доменами)
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|----------|
|
||||
| `GET` | `/api/sources` | Список всех источников с доменами |
|
||||
| `POST` | `/api/sources/{id}/domains` | Добавить домен к источнику `{domain}` |
|
||||
| `DELETE` | `/api/sources/{id}/domains/{domain}` | Удалить домен |
|
||||
| `GET` | `/api/resolve-source?url=` | Определить источник по URL → `{source_id, slug, display_name} \| null` |
|
||||
|
||||
### Управление мангой
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|----------|
|
||||
| `POST` | `/api/mangas/switch-source` | Сменить источник `{url, source_id}` (не во время загрузки) |
|
||||
|
||||
### Pydantic-модели
|
||||
|
||||
```python
|
||||
class DomainAdd(BaseModel):
|
||||
domain: str
|
||||
|
||||
class SourceOut(BaseModel):
|
||||
id: int
|
||||
slug: str
|
||||
display_name: str
|
||||
domains: list[str]
|
||||
settings: dict
|
||||
|
||||
class SwitchSourceRequest(BaseModel):
|
||||
url: str
|
||||
source_id: int
|
||||
# домен всегда перепривязывается автоматически
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Изменения фронтенда
|
||||
|
||||
### Диалог добавления манги
|
||||
|
||||
1. После ввода URL (debounce 400 мс) → GET `/api/resolve-source?url=...`
|
||||
2. **Источник найден** → показать badge «Источник: ReadManga» под полем ввода
|
||||
3. **Источник неизвестен** → показать предупреждение:
|
||||
> ⚠ Домен не распознан. Выберите источник вручную:
|
||||
|
||||
Под предупреждением — `<select>` со списком всех доступных источников. Без выбора источника кнопка «Добавить» неактивна.
|
||||
|
||||
После добавления домен URL автоматически привязывается к выбранному источнику (бэкенд делает это в момент добавления манги).
|
||||
|
||||
### Карточка манги
|
||||
|
||||
- Badge с `source.display_name` рядом с названием (серый, если источник не определён → «Источник неизвестен»)
|
||||
- Кнопка **«↔ Источник»** — видима всегда, кроме статуса `downloading`; открывает модал:
|
||||
- Текущий источник (или «не определён»)
|
||||
- `<select>` со всеми источниками
|
||||
- Статичное предупреждение под select (всегда видимо): «⚠ Домен `xyz.com` будет перепривязан к выбранному источнику. Это затронет все манги с этого домена.»
|
||||
- Кнопка «Применить» → POST `/api/mangas/switch-source`
|
||||
|
||||
### Новая вкладка «Настройки»
|
||||
|
||||
Добавить четвёртую вкладку в навигацию.
|
||||
|
||||
**Подраздел «Источники»** (единственный на данном этапе):
|
||||
|
||||
```
|
||||
┌─ Источники ──────────────────────────────────────────┐
|
||||
│ Источники определяются в коде приложения. │
|
||||
│ Здесь можно управлять доменами для каждого источника│
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐│
|
||||
│ │ ReadManga slug: readmanga ││
|
||||
│ │ Домены: ││
|
||||
│ │ • readmanga.ru [✕] • readmanga.live [✕] ││
|
||||
│ │ • 3.readmanga.ru [✕] [+ добавить домен] ││
|
||||
│ └────────────────────────────────────────────────────┘│
|
||||
│ ┌────────────────────────────────────────────────────┐│
|
||||
│ │ Другой источник slug: other ││
|
||||
│ │ ... ││
|
||||
│ └────────────────────────────────────────────────────┘│
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Inline-редактирование:
|
||||
- `[+ добавить домен]` → inline `<input>` + кнопка «✓» → POST `/api/sources/{id}/domains`
|
||||
- `[✕]` рядом с доменом → DELETE `/api/sources/{id}/domains/{domain}`
|
||||
|
||||
Кнопок «Создать источник» или «Удалить источник» **нет**.
|
||||
|
||||
---
|
||||
|
||||
## 6. WebSocket события
|
||||
|
||||
| `type` | Когда | Данные |
|
||||
|--------|-------|--------|
|
||||
| `source_domain_added` | POST /api/sources/{id}/domains | `{source_id, domain}` |
|
||||
| `source_domain_removed` | DELETE /api/sources/{id}/domains/... | `{source_id, domain}` |
|
||||
| `source_switched` | POST /api/mangas/switch-source | `{url, old_source_id, new_source_id, domain_rebound: true}` |
|
||||
| `source_unknown` | Попытка загрузки манги без источника | `{url}` — фронт показывает уведомление |
|
||||
|
||||
---
|
||||
|
||||
## 7. Решённые вопросы
|
||||
|
||||
### 7.1 CDN-паттерны и настройки источника
|
||||
|
||||
Каждый источник хранит свои технические настройки (CDN-паттерны и т.п.) **только в коде** внутри класса-адаптера. Поле `settings` в таблице `sources` не используется для пользовательского редактирования — оно остаётся зарезервированным для внутренних нужд адаптера. Никакого UI для редактирования настроек нет.
|
||||
|
||||
```python
|
||||
class ReadmangaSource:
|
||||
slug = "readmanga"
|
||||
display_name = "ReadManga"
|
||||
cdn_patterns = ["one-way.work", "staticfa.", "cdnmanga", "reimg"]
|
||||
```
|
||||
|
||||
### 7.2 Домен, уже привязанный к другому источнику
|
||||
|
||||
При смене источника у манги перепривязка домена к новому источнику происходит **автоматически** без дополнительного подтверждения. Флаг `rebind_domain` не нужен.
|
||||
|
||||
В UI рядом с `<select>` источника отображается статичное предупреждение:
|
||||
|
||||
> ⚠ Домен `xyz.com` будет перепривязан к выбранному источнику. Это затронет все манги с этого домена.
|
||||
|
||||
Флаг `rebind_domain` в `SwitchSourceRequest` не нужен — бэкенд всегда перепривязывает домен.
|
||||
|
||||
### 7.3 Удалённые из кода источники
|
||||
|
||||
При старте логировать предупреждение для каждого источника в БД, которого нет в реестре. В UI такие манги показывают badge **«Источник недоступен»** красным цветом. Загрузка таких манг невозможна до смены источника.
|
||||
|
||||
---
|
||||
|
||||
## 8. Порядок реализации (этапы)
|
||||
|
||||
### Этап 1 — БД (без ломки текущей логики)
|
||||
- Добавить таблицы `sources`, `source_domains` в `state.py`
|
||||
- Добавить колонку `source_id` в `mangas`
|
||||
- Реализовать `_sync_sources(registry)` + сидинг readmanga-доменов
|
||||
- Авто-миграция существующих манг (проставить `source_id` по домену)
|
||||
- Новые методы `StateDB`
|
||||
|
||||
### Этап 2 — Адаптер + Реестр
|
||||
- Создать `src/sources/` пакет
|
||||
- Перенести `scraper.py` → `src/sources/readmanga.py` (класс `ReadmangaSource`)
|
||||
- Реализовать `SourceRegistry`, `get_source_for_url()`
|
||||
- Написать shim `src/scraper.py` (обратная совместимость)
|
||||
|
||||
### Этап 3 — Worker + API
|
||||
- Подключить реестр в `worker.py`
|
||||
- Добавить `switch_source()` с перепривязкой домена
|
||||
- Реализовать API эндпоинты (только домены + switch)
|
||||
- WS-события
|
||||
|
||||
### Этап 4 — Фронтенд
|
||||
- Badge источника на карточках манги
|
||||
- Автоопределение при вводе URL + предупреждение + ручной выбор для неизвестных доменов
|
||||
- Диалог смены источника с предупреждением о перепривязке домена
|
||||
- Вкладка «Настройки → Источники»
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ services:
|
||||
- ./state:/app/state
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
# Заставляем glibc возвращать освобождённую память ОС (уменьшает RSS в простое)
|
||||
- MALLOC_MMAP_THRESHOLD_=65536
|
||||
- MALLOC_TRIM_THRESHOLD_=65536
|
||||
# Расписание авто-проверки новых глав (cron-синтаксис).
|
||||
# Примеры: "0 */6 * * *" — каждые 6 ч | "0 3 * * *" — каждый день в 03:00
|
||||
# Оставьте пустым чтобы отключить планировщик.
|
||||
|
||||
@@ -222,6 +222,19 @@
|
||||
</div>
|
||||
<div id="users-list" class="flex flex-col gap-2"></div>
|
||||
</div>
|
||||
<!-- Обновить все метаданные (только admin) -->
|
||||
<div id="refresh-all-section" class="hidden px-5 py-4 border-t border-gray-800">
|
||||
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-1">Обновить все метаданные</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">Запускает браузер для каждой скачанной манги: обновляет обложку, синопсис, жанры и метаданные в файлах CBZ/PDF/EPUB.</p>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button id="refresh-all-btn" onclick="refreshAllMeta()"
|
||||
class="text-xs px-4 py-2 rounded-lg font-semibold text-white"
|
||||
style="background:#312e81;border:1px solid #4338ca;color:#a78bfa">
|
||||
🔄 Обновить все метаданные
|
||||
</button>
|
||||
<div id="refresh-all-status" class="text-xs text-gray-400 hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Смена своего пароля -->
|
||||
<div id="chpwd-section" class="px-5 py-4 border-t border-gray-800">
|
||||
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Сменить пароль</h3>
|
||||
@@ -393,7 +406,6 @@ const state = {
|
||||
currentUser: null, // {id, username, role}
|
||||
authWarnings: {}, // source_slug → {source_slug, source_name}
|
||||
metaUpdating: new Set(), // urls where meta refresh is in progress
|
||||
validating: {}, // url → {checked, total} for in-progress validations
|
||||
};
|
||||
|
||||
// ── Auth ─────────────────────────────────────
|
||||
@@ -704,29 +716,16 @@ function handleEvent(msg) {
|
||||
_updateMetaBtn(msg.url, msg.failed === -1 ? 'error' : 'done');
|
||||
break;
|
||||
|
||||
case 'validate_started':
|
||||
state.validating[msg.url] = {checked: 0, total: 0};
|
||||
_updateValidateBtn(msg.url);
|
||||
case 'refresh_all_started':
|
||||
_handleRefreshAllStarted(msg);
|
||||
break;
|
||||
|
||||
case 'validate_progress':
|
||||
if(state.validating[msg.url]) {
|
||||
state.validating[msg.url].checked = msg.checked;
|
||||
state.validating[msg.url].total = msg.total;
|
||||
}
|
||||
_updateValidateBtn(msg.url);
|
||||
case 'refresh_all_progress':
|
||||
_handleRefreshAllProgress(msg);
|
||||
break;
|
||||
|
||||
case 'validate_done': {
|
||||
delete state.validating[msg.url];
|
||||
const result = msg.total_to_redownload > 0 || msg.new_chapters > 0 ? 'issues' : 'ok';
|
||||
_updateValidateBtn(msg.url, result, msg);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'validate_error':
|
||||
delete state.validating[msg.url];
|
||||
_updateValidateBtn(msg.url, 'error');
|
||||
case 'refresh_all_done':
|
||||
_handleRefreshAllDone(msg);
|
||||
break;
|
||||
|
||||
case 'manga_meta_updated':
|
||||
@@ -838,7 +837,7 @@ function switchTab(tab) {
|
||||
document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas');
|
||||
if(tab === 'history') loadHistory();
|
||||
if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); }
|
||||
if(tab === 'settings') { loadSources(); showUsersSection(); }
|
||||
if(tab === 'settings') { loadSources(); showUsersSection(); showRefreshAllSection(); }
|
||||
}
|
||||
|
||||
function updateNewsBadge() {
|
||||
@@ -1158,6 +1157,11 @@ function showUsersSection() {
|
||||
}
|
||||
}
|
||||
|
||||
function showRefreshAllSection() {
|
||||
const el = document.getElementById('refresh-all-section');
|
||||
if(el) el.classList.toggle('hidden', !isAdmin());
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if(!isAdmin()) return;
|
||||
try {
|
||||
@@ -1680,7 +1684,7 @@ function _updateMetaBtn(url, result) {
|
||||
btn.style.color = '#4ade80';
|
||||
btn.style.borderColor = '#166534';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = '🏷 Обновить метатеги';
|
||||
btn.innerHTML = '🏷 Обновить метаданные';
|
||||
btn.style.color = '#a78bfa';
|
||||
btn.style.borderColor = '#312e81';
|
||||
}, 2500);
|
||||
@@ -1690,12 +1694,12 @@ function _updateMetaBtn(url, result) {
|
||||
btn.style.color = '#f87171';
|
||||
btn.style.borderColor = '#7f1d1d';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = '🏷 Обновить метатеги';
|
||||
btn.innerHTML = '🏷 Обновить метаданные';
|
||||
btn.style.color = '#a78bfa';
|
||||
btn.style.borderColor = '#312e81';
|
||||
}, 3000);
|
||||
} else {
|
||||
btn.innerHTML = '🏷 Обновить метатеги';
|
||||
btn.innerHTML = '🏷 Обновить метаданные';
|
||||
btn.disabled = false;
|
||||
btn.style.color = '#a78bfa';
|
||||
btn.style.borderColor = '#312e81';
|
||||
@@ -1717,60 +1721,43 @@ async function refreshMetaModal(url) {
|
||||
// Спиннер появится через WS meta_refresh_started, исчезнет через meta_refreshed
|
||||
}
|
||||
|
||||
function _updateValidateBtn(url, result, data) {
|
||||
const modal = document.getElementById('modal');
|
||||
if(!modal || modal.classList.contains('hidden') || modal.dataset.currentUrl !== url) return;
|
||||
const btn = document.getElementById('modal-validate-btn');
|
||||
if(!btn) return;
|
||||
const v = state.validating[url];
|
||||
if(v !== undefined) {
|
||||
const prog = v.total > 0 ? ` ${v.checked}/${v.total}` : '...';
|
||||
btn.innerHTML = `<span class="meta-spinner"></span> Проверка${prog}`;
|
||||
btn.disabled = true;
|
||||
btn.style.color = '#94a3b8';
|
||||
btn.style.borderColor = '#334155';
|
||||
} else if(result === 'ok') {
|
||||
btn.innerHTML = '✅ Всё в порядке';
|
||||
btn.disabled = false;
|
||||
btn.style.color = '#4ade80';
|
||||
btn.style.borderColor = '#166534';
|
||||
setTimeout(() => { btn.innerHTML = '🔍 Проверить целостность'; btn.style.color='#67e8f9'; btn.style.borderColor='#164e63'; btn.disabled=false; }, 3000);
|
||||
} else if(result === 'issues') {
|
||||
const n = data ? (data.total_to_redownload + data.new_chapters) : '?';
|
||||
btn.innerHTML = `⚡ Найдено проблем: ${n} — поставлено в очередь`;
|
||||
btn.disabled = true;
|
||||
btn.style.color = '#fbbf24';
|
||||
btn.style.borderColor = '#78350f';
|
||||
setTimeout(() => { btn.innerHTML = '🔍 Проверить целостность'; btn.style.color='#67e8f9'; btn.style.borderColor='#164e63'; btn.disabled=false; }, 5000);
|
||||
} else if(result === 'error') {
|
||||
btn.innerHTML = '❌ Ошибка валидации';
|
||||
btn.disabled = false;
|
||||
btn.style.color = '#f87171';
|
||||
btn.style.borderColor = '#7f1d1d';
|
||||
setTimeout(() => { btn.innerHTML = '🔍 Проверить целостность'; btn.style.color='#67e8f9'; btn.style.borderColor='#164e63'; btn.disabled=false; }, 3000);
|
||||
} else {
|
||||
btn.innerHTML = '🔍 Проверить целостность';
|
||||
btn.disabled = false;
|
||||
btn.style.color = '#67e8f9';
|
||||
btn.style.borderColor = '#164e63';
|
||||
async function refreshAllMeta() {
|
||||
const btn = document.getElementById('refresh-all-btn');
|
||||
const status = document.getElementById('refresh-all-status');
|
||||
const r = await fetch('/api/mangas/refresh_all_meta', {method:'POST'});
|
||||
if(!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
if(status) { status.textContent = err.detail || 'Ошибка запуска'; status.classList.remove('hidden'); status.style.color = '#f87171'; }
|
||||
return;
|
||||
}
|
||||
if(btn) { btn.disabled = true; btn.textContent = '⏳ Запускаем...'; }
|
||||
if(status) { status.textContent = 'Инициализация...'; status.classList.remove('hidden'); status.style.color = '#94a3b8'; }
|
||||
}
|
||||
|
||||
function _handleRefreshAllStarted(data) {
|
||||
const btn = document.getElementById('refresh-all-btn');
|
||||
const status = document.getElementById('refresh-all-status');
|
||||
if(btn) { btn.disabled = true; btn.textContent = '⏳ Обновляем...'; }
|
||||
if(status) { status.textContent = `0 / ${data.total}`; status.classList.remove('hidden'); status.style.color = '#94a3b8'; }
|
||||
}
|
||||
|
||||
function _handleRefreshAllProgress(data) {
|
||||
const status = document.getElementById('refresh-all-status');
|
||||
if(status) {
|
||||
const title = data.title ? ` — ${data.title}` : '';
|
||||
status.textContent = `${data.done + 1} / ${data.total}${title}`;
|
||||
status.style.color = '#94a3b8';
|
||||
}
|
||||
}
|
||||
|
||||
async function validateManga(url) {
|
||||
const btn = document.getElementById('modal-validate-btn');
|
||||
if(btn) {
|
||||
btn.innerHTML = '<span class="meta-spinner"></span> Запуск...';
|
||||
btn.disabled = true;
|
||||
}
|
||||
const r = await fetch('/api/mangas/validate?url='+encodeURIComponent(url), {method:'POST'});
|
||||
if(!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
if(btn) {
|
||||
btn.innerHTML = '❌ ' + (err.detail || 'Ошибка');
|
||||
btn.style.color = '#f87171';
|
||||
btn.style.borderColor = '#7f1d1d';
|
||||
setTimeout(() => { btn.innerHTML = '🔍 Проверить целостность'; btn.style.color='#67e8f9'; btn.style.borderColor='#164e63'; btn.disabled=false; }, 3000);
|
||||
}
|
||||
function _handleRefreshAllDone(data) {
|
||||
const btn = document.getElementById('refresh-all-btn');
|
||||
const status = document.getElementById('refresh-all-status');
|
||||
if(btn) { btn.disabled = false; btn.textContent = '🔄 Обновить все метаданные'; }
|
||||
if(status) {
|
||||
status.textContent = `Готово: ${data.total} манг, обновлено файлов: ${data.total_updated}`;
|
||||
status.style.color = '#4ade80';
|
||||
status.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2316,19 +2303,10 @@ function renderModalBody(data) {
|
||||
📁 Переименовать папку
|
||||
</button>` : ''}
|
||||
${data.status === 'done' && canManage(data) ? `
|
||||
<button id="modal-validate-btn" onclick="validateManga('${escHtml(data.url)}')"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
||||
style="background:#0c1a2e;color:#67e8f9;border:1px solid #164e63"
|
||||
${state.validating[data.url] !== undefined ? 'disabled' : ''}>
|
||||
${state.validating[data.url] !== undefined
|
||||
? `<span class="meta-spinner"></span> Проверка${state.validating[data.url].total > 0 ? ' '+state.validating[data.url].checked+'/'+state.validating[data.url].total : '...'}`
|
||||
: '🔍 Проверить целостность'}
|
||||
</button>` : ''}
|
||||
${data.status === 'done' && canManage(data) ? `
|
||||
<button id="modal-refresh-meta-btn" onclick="refreshMetaModal('${escHtml(data.url)}')"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
||||
style="background:#1e1b4b;color:#a78bfa;border:1px solid #312e81">
|
||||
🏷 Обновить метатеги
|
||||
🏷 Обновить метаданные
|
||||
</button>` : ''}
|
||||
${data.status !== 'downloading' && data.status !== 'queued' && isAdmin() ? `
|
||||
<button onclick="forceRedownloadModal('${escHtml(data.url)}')"
|
||||
|
||||
@@ -6,7 +6,7 @@ ebooklib==0.18
|
||||
tqdm==4.66.4
|
||||
loguru==0.7.2
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
uvicorn==0.29.0
|
||||
websockets==12.0
|
||||
pypdf==4.2.0
|
||||
croniter==3.0.3
|
||||
|
||||
208
src/api.py
208
src/api.py
@@ -3,6 +3,8 @@ FastAPI веб-сервер: REST API + WebSocket для мониторинга
|
||||
Многопользовательская система с ролями admin / user.
|
||||
"""
|
||||
import asyncio
|
||||
import ctypes
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -16,7 +18,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
from .state import StateDB
|
||||
from .worker import download_manga, check_for_updates, validate_manga
|
||||
from .worker import download_manga, check_for_updates, refresh_manga_metadata
|
||||
from .browser import BrowserManager
|
||||
from .exporter import patch_meta, MangaMeta
|
||||
from .sources import registry, get_source_for_url, extract_domain
|
||||
@@ -71,6 +73,7 @@ ws_manager = ConnectionManager()
|
||||
# ── Очередь загрузки ─────────────────────────
|
||||
download_queue: asyncio.Queue = asyncio.Queue()
|
||||
active_tasks: dict = {}
|
||||
_refresh_all_running: bool = False
|
||||
async def _broadcast_queue_positions():
|
||||
queue_list = list(download_queue._queue)
|
||||
positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)}
|
||||
@@ -154,6 +157,7 @@ async def startup_event():
|
||||
_db.close()
|
||||
asyncio.create_task(queue_worker())
|
||||
asyncio.create_task(update_scheduler())
|
||||
asyncio.create_task(memory_trimmer())
|
||||
db = StateDB()
|
||||
try:
|
||||
for manga in db.get_all_mangas():
|
||||
@@ -188,6 +192,17 @@ def _parse_schedule() -> Optional[str]:
|
||||
except ValueError:
|
||||
logger.error("UPDATE_INTERVAL_HOURS='{}' — не число", hours_raw)
|
||||
return None
|
||||
async def memory_trimmer():
|
||||
"""Периодически принудительно возвращает неиспользуемую память ОС."""
|
||||
while True:
|
||||
await asyncio.sleep(600) # каждые 10 минут
|
||||
gc.collect()
|
||||
try:
|
||||
ctypes.CDLL("libc.so.6").malloc_trim(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def update_scheduler():
|
||||
cron_expr = _parse_schedule()
|
||||
if not cron_expr:
|
||||
@@ -679,106 +694,75 @@ async def refresh_meta(url: str, current_user: dict = Depends(get_current_user))
|
||||
db.close()
|
||||
asyncio.create_task(_do_refresh_meta(url))
|
||||
return {"ok": True}
|
||||
def _patch_meta_sync(manga: dict, chapters: list, chapters_total: int, pub_status: str) -> tuple[int, int]:
|
||||
updated = failed = 0
|
||||
url = manga["url"]
|
||||
summary = manga.get("description") or ""
|
||||
tags_raw = manga.get("tags") or ""
|
||||
try:
|
||||
tags_str = ", ".join(json.loads(tags_raw)) if tags_raw else ""
|
||||
except Exception:
|
||||
tags_str = ""
|
||||
for ch in chapters:
|
||||
for fmt_col in ("output_cbz", "output_pdf", "output_epub"):
|
||||
fpath = ch.get(fmt_col)
|
||||
if not fpath:
|
||||
continue
|
||||
p = Path(fpath)
|
||||
if not p.exists():
|
||||
continue
|
||||
meta = MangaMeta(
|
||||
series=manga.get("title_ru") or manga.get("title") or "",
|
||||
series_full=manga.get("title_full") or "",
|
||||
chapter_title=ch.get("title") or "",
|
||||
number=float(ch.get("number") or 0),
|
||||
volume=int(ch.get("volume") or 0),
|
||||
chapters_total=chapters_total,
|
||||
pub_status=pub_status,
|
||||
source_url=url,
|
||||
summary=summary,
|
||||
tags=tags_str,
|
||||
)
|
||||
if patch_meta(p, meta):
|
||||
updated += 1
|
||||
else:
|
||||
failed += 1
|
||||
return updated, failed
|
||||
|
||||
def _refresh_cover_sync(manga: dict, manga_dir: Path) -> None:
|
||||
"""Скачивает или обновляет обложку через urllib (синхронно, для asyncio.to_thread)."""
|
||||
import urllib.request as _urllib_req
|
||||
import re as _re
|
||||
|
||||
cover_url = manga.get("cover_url") or ""
|
||||
if not cover_url:
|
||||
return
|
||||
|
||||
# Определяем Referer по URL обложки (MangaLib CDN — cdnlibs / mangalib)
|
||||
if any(pat in cover_url for pat in ("mangalib", "cdnlibs", "imglib")):
|
||||
referer = "https://mangalib.me/"
|
||||
else:
|
||||
from urllib.parse import urlparse as _up
|
||||
parsed = _up(manga.get("url") or "")
|
||||
referer = f"{parsed.scheme}://{parsed.netloc}/" if parsed.netloc else "https://readmanga.ru/"
|
||||
|
||||
try:
|
||||
req = _urllib_req.Request(cover_url, headers={
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124.0.0.0",
|
||||
"Referer": referer,
|
||||
"Accept": "image/png,image/jpeg,image/webp,image/*,*/*",
|
||||
})
|
||||
with _urllib_req.urlopen(req, timeout=30) as resp:
|
||||
body = resp.read()
|
||||
if len(body) < 500:
|
||||
logger.warning("refresh_cover: слишком малый ответ ({} байт)", len(body))
|
||||
return
|
||||
m = _re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", cover_url, _re.IGNORECASE)
|
||||
ext = ("." + (m.group(1).lower() if m else "jpg")).replace(".jpeg", ".jpg")
|
||||
cover_path = manga_dir / f"cover{ext}"
|
||||
cover_path.write_bytes(body)
|
||||
logger.info("Обложка обновлена: {} ({} байт)", cover_path.name, len(body))
|
||||
except Exception as e:
|
||||
logger.warning("refresh_cover error {}: {}", cover_url, e)
|
||||
|
||||
|
||||
async def _do_refresh_meta(url: str):
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
return
|
||||
chapters = db.get_all_chapters(url)
|
||||
chapters_total = len(chapters)
|
||||
pub_status = manga.get("pub_status", "unknown") or "unknown"
|
||||
finally:
|
||||
db.close()
|
||||
try:
|
||||
await ws_manager.broadcast({"type": "meta_refresh_started", "url": url})
|
||||
updated, failed = await asyncio.to_thread(_patch_meta_sync, manga, chapters, chapters_total, pub_status)
|
||||
logger.info("refresh_meta {}: обновлено {}, ошибок {}", url, updated, failed)
|
||||
|
||||
# Обновляем обложку если у манги формат cbz
|
||||
manga_fmt = manga.get("format", "cbz") or "cbz"
|
||||
if manga_fmt in ("cbz", "all") and manga.get("cover_url"):
|
||||
manga_dir = _manga_folder(manga)
|
||||
if manga_dir.exists():
|
||||
await asyncio.to_thread(_refresh_cover_sync, manga, manga_dir)
|
||||
|
||||
updated, failed = await refresh_manga_metadata(url)
|
||||
await ws_manager.broadcast({"type": "meta_refreshed", "url": url,
|
||||
"updated": updated, "failed": failed})
|
||||
except Exception as e:
|
||||
logger.error("_do_refresh_meta {}: {}", url, e)
|
||||
await ws_manager.broadcast({"type": "meta_refreshed", "url": url, "updated": 0, "failed": -1})
|
||||
|
||||
|
||||
@app.post("/api/mangas/refresh_all_meta")
|
||||
async def refresh_all_meta_endpoint(current_user: dict = Depends(require_admin)):
|
||||
global _refresh_all_running
|
||||
if _refresh_all_running:
|
||||
raise HTTPException(status_code=409, detail="Обновление метаданных уже выполняется")
|
||||
asyncio.create_task(_do_refresh_all_meta())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/mangas/refresh_all_meta/status")
|
||||
async def refresh_all_meta_status(current_user: dict = Depends(require_admin)):
|
||||
return {"running": _refresh_all_running}
|
||||
|
||||
|
||||
async def _do_refresh_all_meta():
|
||||
global _refresh_all_running
|
||||
_refresh_all_running = True
|
||||
db = StateDB()
|
||||
try:
|
||||
mangas = db.get_all_mangas()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
done_mangas = [m for m in mangas if m["status"] == "done"]
|
||||
total = len(done_mangas)
|
||||
await ws_manager.broadcast({"type": "refresh_all_started", "total": total})
|
||||
logger.info("refresh_all_meta: начало, всего манг: {}", total)
|
||||
|
||||
total_updated = total_failed = 0
|
||||
try:
|
||||
for i, manga in enumerate(done_mangas):
|
||||
url = manga["url"]
|
||||
await ws_manager.broadcast({
|
||||
"type": "refresh_all_progress",
|
||||
"done": i,
|
||||
"total": total,
|
||||
"url": url,
|
||||
"title": manga.get("title_ru") or manga.get("title") or url,
|
||||
})
|
||||
try:
|
||||
updated, failed = await refresh_manga_metadata(url)
|
||||
total_updated += updated
|
||||
total_failed += failed
|
||||
except Exception as e:
|
||||
logger.error("refresh_all_meta {}: {}", url, e)
|
||||
|
||||
await ws_manager.broadcast({
|
||||
"type": "refresh_all_done",
|
||||
"total": total,
|
||||
"total_updated": total_updated,
|
||||
"total_failed": total_failed,
|
||||
})
|
||||
logger.info("refresh_all_meta: завершено, обновлено файлов: {}, ошибок: {}",
|
||||
total_updated, total_failed)
|
||||
finally:
|
||||
_refresh_all_running = False
|
||||
|
||||
|
||||
class UpdateMetaRequest(BaseModel):
|
||||
url: str
|
||||
title_ru: str
|
||||
@@ -849,44 +833,6 @@ async def force_redownload(url: str, _: dict = Depends(require_admin)):
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
@app.post("/api/mangas/validate")
|
||||
async def validate_manga_endpoint(url: str, current_user: dict = Depends(get_current_user)):
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
if manga["status"] != "done":
|
||||
raise HTTPException(status_code=400, detail="Валидация доступна только для манг в статусе 'Готово'")
|
||||
_check_manga_access(manga, current_user)
|
||||
finally:
|
||||
db.close()
|
||||
asyncio.create_task(_do_validate(url))
|
||||
return {"ok": True}
|
||||
async def _do_validate(url: str):
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
fmt = manga["format"] if manga else "cbz"
|
||||
finally:
|
||||
db.close()
|
||||
result = await validate_manga(url, on_event=ws_manager.broadcast)
|
||||
if not result.get("ok"):
|
||||
return
|
||||
chapters_to_retry = result.get("chapters_to_redownload", [])
|
||||
new_chapters = result.get("new_chapters", 0)
|
||||
if not chapters_to_retry and not new_chapters:
|
||||
return
|
||||
db2 = StateDB()
|
||||
try:
|
||||
for chapter_url in chapters_to_retry:
|
||||
db2.reset_chapter(chapter_url)
|
||||
db2.update_manga_status(url, "queued")
|
||||
finally:
|
||||
db2.close()
|
||||
await download_queue.put({"url": url, "fmt": fmt})
|
||||
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": fmt})
|
||||
await _broadcast_queue_positions()
|
||||
@app.post("/api/mangas/stop")
|
||||
async def stop_manga(url: str, current_user: dict = Depends(get_current_user)):
|
||||
db = StateDB()
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""
|
||||
Браузерный слой: запуск Playwright Chromium с антидетект-настройками.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from loguru import logger
|
||||
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.async_api import Browser, BrowserContext, Page
|
||||
|
||||
|
||||
# Реалистичный User-Agent Chrome 124 Linux
|
||||
@@ -53,6 +57,7 @@ class BrowserManager:
|
||||
self._browser: Optional[Browser] = None
|
||||
|
||||
async def start(self):
|
||||
from playwright.async_api import async_playwright
|
||||
self._playwright = await async_playwright().start()
|
||||
self._browser = await self._playwright.chromium.launch(
|
||||
headless=self.headless,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""
|
||||
Базовые модели данных и Protocol-интерфейс для источников манги.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional, Protocol, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Optional, Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.async_api import Page
|
||||
|
||||
|
||||
|
||||
@@ -9,15 +9,19 @@
|
||||
Получаем pages[] с полями: image (filename), url (relative path), slug (page index 1-based).
|
||||
Изображения: {server}{page.url}, CDN = mixlib.me / imglib.info.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.async_api import Page
|
||||
|
||||
from .base import Chapter, MangaInfo, AuthRequiredError
|
||||
@@ -382,59 +386,6 @@ class MangalibSource:
|
||||
|
||||
return [paths[i] for i in sorted(paths.keys())]
|
||||
|
||||
async def get_chapter_page_count(
|
||||
self, page: Page, chapter_url: str, manga_url: Optional[str] = None
|
||||
) -> int:
|
||||
"""Открывает главу и возвращает количество страниц через API без скачивания изображений."""
|
||||
pages_info: list = []
|
||||
auth_err: list = []
|
||||
lock = asyncio.Lock()
|
||||
|
||||
async def on_response(resp):
|
||||
try:
|
||||
if "api.cdnlibs.org" in resp.url and "/chapter?" in resp.url:
|
||||
if resp.status in (401, 403):
|
||||
auth_err.append(True)
|
||||
return
|
||||
body = await resp.body()
|
||||
data = _json.loads(body)
|
||||
async with lock:
|
||||
if not pages_info:
|
||||
pages_info.extend(data.get("data", {}).get("pages", []))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.auth_token:
|
||||
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
|
||||
page.on("response", on_response)
|
||||
|
||||
referer = manga_url or _base_url(chapter_url)
|
||||
ok = await _navigate(page, chapter_url, referer=referer)
|
||||
if not ok:
|
||||
mirror_url = _switch_to_mirror(chapter_url)
|
||||
if mirror_url != chapter_url:
|
||||
ok = await _navigate(
|
||||
page, mirror_url,
|
||||
referer=_switch_to_mirror(referer) if referer else referer,
|
||||
)
|
||||
|
||||
if not ok:
|
||||
page.remove_listener("response", on_response)
|
||||
return 0
|
||||
|
||||
for _ in range(40):
|
||||
async with lock:
|
||||
if pages_info or auth_err:
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
page.remove_listener("response", on_response)
|
||||
|
||||
if auth_err and not pages_info:
|
||||
raise AuthRequiredError(self.slug)
|
||||
|
||||
return len(pages_info)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Вспомогательные функции (приватные)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
"""
|
||||
Адаптер ReadManga: поддерживает readmanga.ru и все его клоны.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.async_api import Page
|
||||
|
||||
from .base import Chapter, MangaInfo
|
||||
@@ -376,19 +380,6 @@ class ReadmangaSource:
|
||||
|
||||
return [paths[i] for i in sorted(paths.keys())]
|
||||
|
||||
async def get_chapter_page_count(
|
||||
self, page: Page, chapter_url: str, manga_url: Optional[str] = None
|
||||
) -> int:
|
||||
"""Открывает главу и возвращает количество страниц без скачивания изображений."""
|
||||
load_url = chapter_url + ("?mtr=1" if "?" not in chapter_url else "&mtr=1")
|
||||
ok = await _navigate(page, load_url)
|
||||
if not ok:
|
||||
return 0
|
||||
urls = await _extract_images_from_js(page)
|
||||
if not urls:
|
||||
urls = await _extract_images_from_dom(page)
|
||||
return len(urls)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Вспомогательные функции (приватные)
|
||||
|
||||
329
src/worker.py
329
src/worker.py
@@ -13,7 +13,7 @@ from .browser import BrowserManager
|
||||
from .sources import registry, get_source_for_url, extract_domain
|
||||
import json as _json
|
||||
from .sources.base import Chapter, MangaInfo, AuthRequiredError
|
||||
from .exporter import export, MangaMeta
|
||||
from .exporter import export, patch_meta, MangaMeta
|
||||
from .state import StateDB
|
||||
from .utils import safe_name, safe_chapter_name
|
||||
|
||||
@@ -406,207 +406,6 @@ async def download_manga(
|
||||
db.close()
|
||||
|
||||
|
||||
async def validate_manga(
|
||||
url: str,
|
||||
output_dir: Path = OUTPUT_DIR,
|
||||
on_event=None,
|
||||
) -> dict:
|
||||
"""
|
||||
Проверяет целостность скачанной манги, сравнивая с сайтом.
|
||||
|
||||
- Получает актуальный список глав с сайта
|
||||
- Добавляет новые главы в БД
|
||||
- Для скачанных глав: проверяет наличие файлов и количество страниц
|
||||
- Возвращает dict с chapters_to_redownload и статистикой
|
||||
"""
|
||||
|
||||
async def emit(event: dict):
|
||||
if on_event:
|
||||
try:
|
||||
await on_event(event)
|
||||
except Exception as e:
|
||||
logger.debug("on_event error: {}", e)
|
||||
|
||||
db = StateDB()
|
||||
db_lock = asyncio.Lock()
|
||||
|
||||
async def db_call(fn, *args, **kwargs):
|
||||
async with db_lock:
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
try:
|
||||
await emit({"type": "validate_started", "url": url})
|
||||
|
||||
source = get_source_for_url(url, db)
|
||||
if source is None:
|
||||
manga_row = await db_call(db.get_manga, url)
|
||||
if manga_row and manga_row.get("source_id"):
|
||||
source = registry.get_by_db_id(manga_row["source_id"], db)
|
||||
if source is None:
|
||||
await emit({"type": "validate_error", "url": url,
|
||||
"error": "Источник не определён. Выберите источник в настройках манги."})
|
||||
return {"ok": False, "chapters_to_redownload": []}
|
||||
|
||||
if hasattr(source, "auth_token"):
|
||||
_src_row = await db_call(db.get_source_by_slug, source.slug)
|
||||
if _src_row:
|
||||
_settings_raw = _src_row.get("settings") or "{}"
|
||||
try:
|
||||
_settings = _json.loads(_settings_raw) if isinstance(_settings_raw, str) else (_settings_raw or {})
|
||||
except Exception:
|
||||
_settings = {}
|
||||
source.auth_token = _settings.get("auth_token") or None
|
||||
|
||||
manga_row = await db_call(db.get_manga, url)
|
||||
fmt = (manga_row or {}).get("format", "cbz")
|
||||
fmt_list = ["cbz", "pdf", "epub"] if fmt == "all" else [fmt]
|
||||
|
||||
async with BrowserManager(headless=True) as bm:
|
||||
ctx, info_page = await bm.new_page()
|
||||
try:
|
||||
manga = await source.get_manga_info(info_page, url)
|
||||
except Exception as e:
|
||||
logger.error("validate: get_manga_info ошибка для {}: {}", url, e)
|
||||
await emit({"type": "validate_error", "url": url, "error": str(e)})
|
||||
return {"ok": False, "chapters_to_redownload": []}
|
||||
finally:
|
||||
await info_page.close()
|
||||
|
||||
if not manga:
|
||||
await emit({"type": "validate_error", "url": url,
|
||||
"error": "Не удалось получить информацию о манге с сайта"})
|
||||
return {"ok": False, "chapters_to_redownload": []}
|
||||
|
||||
for ch in manga.chapters:
|
||||
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
|
||||
|
||||
all_ch_rows = await db_call(db.get_all_chapters, url)
|
||||
db_chapters = {c["chapter_url"]: c for c in all_ch_rows}
|
||||
|
||||
new_chapters = [
|
||||
ch for ch in manga.chapters
|
||||
if db_chapters.get(ch.url, {}).get("status") == "pending"
|
||||
]
|
||||
|
||||
done_chapters = [
|
||||
ch for ch in manga.chapters
|
||||
if db_chapters.get(ch.url, {}).get("status") == "done"
|
||||
]
|
||||
|
||||
to_redownload: set = set()
|
||||
fast_issues = 0
|
||||
|
||||
for ch in done_chapters:
|
||||
db_ch = db_chapters[ch.url]
|
||||
if db_ch.get("pages_total", 0) > 0 and db_ch.get("pages_done", 0) < db_ch["pages_total"]:
|
||||
to_redownload.add(ch.url)
|
||||
fast_issues += 1
|
||||
continue
|
||||
for f in fmt_list:
|
||||
fpath = db_ch.get(f"output_{f}")
|
||||
if fpath and not Path(fpath).exists():
|
||||
to_redownload.add(ch.url)
|
||||
fast_issues += 1
|
||||
break
|
||||
|
||||
chapters_for_deep = [
|
||||
ch for ch in done_chapters if ch.url not in to_redownload
|
||||
]
|
||||
site_mismatched = 0
|
||||
checked = 0
|
||||
has_page_count = hasattr(source, "get_chapter_page_count")
|
||||
|
||||
if has_page_count and chapters_for_deep:
|
||||
sem = asyncio.Semaphore(2)
|
||||
count_lock = asyncio.Lock()
|
||||
|
||||
async def check_one(ch: Chapter) -> None:
|
||||
nonlocal checked, site_mismatched
|
||||
async with sem:
|
||||
db_ch = db_chapters[ch.url]
|
||||
ch_page = await ctx.new_page()
|
||||
mismatch = False
|
||||
site_count = 0
|
||||
try:
|
||||
site_count = await source.get_chapter_page_count(
|
||||
ch_page, ch.url, url
|
||||
)
|
||||
except AuthRequiredError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"validate page count Т{} Гл.{}: {}", ch.volume, ch.number, e
|
||||
)
|
||||
finally:
|
||||
await ch_page.close()
|
||||
|
||||
pages_have = db_ch.get("pages_done", 0)
|
||||
if site_count > 0 and site_count != pages_have:
|
||||
mismatch = True
|
||||
logger.info(
|
||||
"validate: Т{} Гл.{} — сайт {} стр., у нас {} → повтор",
|
||||
ch.volume, ch.number, site_count, pages_have,
|
||||
)
|
||||
|
||||
async with count_lock:
|
||||
checked += 1
|
||||
if mismatch:
|
||||
to_redownload.add(ch.url)
|
||||
site_mismatched += 1
|
||||
|
||||
await emit({
|
||||
"type": "validate_progress",
|
||||
"url": url,
|
||||
"checked": checked,
|
||||
"total": len(chapters_for_deep),
|
||||
"chapter_number": ch.number,
|
||||
"volume": ch.volume,
|
||||
"mismatch": mismatch,
|
||||
"site_count": site_count,
|
||||
})
|
||||
|
||||
results = await asyncio.gather(
|
||||
*[check_one(ch) for ch in chapters_for_deep],
|
||||
return_exceptions=True,
|
||||
)
|
||||
auth_slug = None
|
||||
for res in results:
|
||||
if isinstance(res, AuthRequiredError):
|
||||
auth_slug = res.source_slug
|
||||
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
|
||||
logger.exception("validate gather exception: {}", res)
|
||||
if auth_slug:
|
||||
await emit({"type": "validate_error", "url": url,
|
||||
"error": f"auth_required:{auth_slug}"})
|
||||
return {"ok": False, "chapters_to_redownload": []}
|
||||
|
||||
to_redownload_list = list(to_redownload)
|
||||
result = {
|
||||
"ok": True,
|
||||
"url": url,
|
||||
"site_chapters": len(manga.chapters),
|
||||
"new_chapters": len(new_chapters),
|
||||
"fast_issues": fast_issues,
|
||||
"site_mismatched": site_mismatched,
|
||||
"total_to_redownload": len(to_redownload_list),
|
||||
"chapters_to_redownload": to_redownload_list,
|
||||
}
|
||||
await emit({
|
||||
"type": "validate_done",
|
||||
**{k: v for k, v in result.items() if k != "chapters_to_redownload"},
|
||||
})
|
||||
return result
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("validate_manga {}: {}", url, e)
|
||||
await emit({"type": "validate_error", "url": url, "error": str(e)})
|
||||
return {"ok": False, "chapters_to_redownload": []}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _cover_ext_from_url(url: str) -> str:
|
||||
import re as _re
|
||||
m = _re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, _re.IGNORECASE)
|
||||
@@ -756,3 +555,129 @@ async def check_for_updates(
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def refresh_manga_metadata(
|
||||
url: str,
|
||||
output_dir: Path = OUTPUT_DIR,
|
||||
on_event: Optional[Callable] = None,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Обновляет метаданные манги через браузер: скачивает обложку через Playwright,
|
||||
обновляет ComicInfo.xml/PDF/EPUB с актуальными данными (включая жанры и синопсис).
|
||||
Возвращает (updated, failed).
|
||||
"""
|
||||
async def emit(event: dict):
|
||||
if on_event:
|
||||
try:
|
||||
await on_event(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db = StateDB()
|
||||
db_lock = asyncio.Lock()
|
||||
|
||||
async def db_call(fn, *args, **kwargs):
|
||||
async with db_lock:
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
try:
|
||||
source = get_source_for_url(url, db)
|
||||
if source is None:
|
||||
manga_row = await db_call(db.get_manga, url)
|
||||
if manga_row and manga_row.get("source_id"):
|
||||
source = registry.get_by_db_id(manga_row["source_id"], db)
|
||||
if source is None:
|
||||
logger.warning("refresh_manga_metadata: источник не найден для {}", url)
|
||||
return 0, 0
|
||||
|
||||
# Inject auth token for sources that need it
|
||||
if hasattr(source, "auth_token"):
|
||||
_src_row = await db_call(db.get_source_by_slug, source.slug)
|
||||
if _src_row:
|
||||
_settings_raw = _src_row.get("settings") or "{}"
|
||||
try:
|
||||
_settings = _json.loads(_settings_raw) if isinstance(_settings_raw, str) else (_settings_raw or {})
|
||||
except Exception:
|
||||
_settings = {}
|
||||
source.auth_token = _settings.get("auth_token") or None
|
||||
|
||||
async with BrowserManager(headless=True) as bm:
|
||||
_, page = await bm.new_page()
|
||||
try:
|
||||
manga = await source.get_manga_info(page, url)
|
||||
if not manga:
|
||||
logger.warning("refresh_manga_metadata: get_manga_info вернул None для {}", url)
|
||||
return 0, 0
|
||||
|
||||
# Сохраняем свежие данные в БД
|
||||
await db_call(
|
||||
db.update_manga_info, url,
|
||||
title=manga.title_ru or manga.title,
|
||||
chapters_total=len(manga.chapters),
|
||||
title_ru=manga.title_ru,
|
||||
title_full=manga.title_full,
|
||||
pub_status=manga.pub_status,
|
||||
description=manga.description,
|
||||
tags=_json.dumps(manga.tags, ensure_ascii=False) if manga.tags else "",
|
||||
cover_url=manga.cover_url,
|
||||
)
|
||||
|
||||
# Скачиваем обложку через Playwright (правильные куки/заголовки)
|
||||
manga_row = await db_call(db.get_manga, url)
|
||||
manga_fmt = (manga_row or {}).get("format", "cbz") or "cbz"
|
||||
folder_name = (
|
||||
(manga_row.get("folder_name") if manga_row else None)
|
||||
or safe_name(manga.title_ru or manga.title)
|
||||
)
|
||||
manga_dir = output_dir / folder_name
|
||||
|
||||
if manga.cover_url and manga_fmt in ("cbz", "all") and manga_dir.exists():
|
||||
await _download_cover(manga.cover_url, manga_dir, url, page)
|
||||
|
||||
# Обновляем метаданные в файлах с актуальными данными из источника
|
||||
chapters = await db_call(db.get_all_chapters, url)
|
||||
chapters_total = len(chapters)
|
||||
series = manga.title_ru or manga.title
|
||||
series_full = manga.title_full or ""
|
||||
pub_status = manga.pub_status or "unknown"
|
||||
summary = manga.description or ""
|
||||
tags_str = ", ".join(manga.tags) if manga.tags else ""
|
||||
genre_str = ", ".join(manga.genres) if manga.genres else ""
|
||||
|
||||
def do_patch():
|
||||
updated = failed = 0
|
||||
for ch in chapters:
|
||||
for fmt_col in ("output_cbz", "output_pdf", "output_epub"):
|
||||
fpath = ch.get(fmt_col)
|
||||
if not fpath:
|
||||
continue
|
||||
p = Path(fpath)
|
||||
if not p.exists():
|
||||
continue
|
||||
meta = MangaMeta(
|
||||
series=series,
|
||||
series_full=series_full,
|
||||
chapter_title=ch.get("title") or "",
|
||||
number=float(ch.get("number") or 0),
|
||||
volume=int(ch.get("volume") or 0),
|
||||
chapters_total=chapters_total,
|
||||
pub_status=pub_status,
|
||||
source_url=url,
|
||||
summary=summary,
|
||||
tags=tags_str,
|
||||
genre=genre_str,
|
||||
)
|
||||
if patch_meta(p, meta):
|
||||
updated += 1
|
||||
else:
|
||||
failed += 1
|
||||
return updated, failed
|
||||
|
||||
updated, failed = await asyncio.to_thread(do_patch)
|
||||
logger.info("refresh_manga_metadata {}: обновлено {}, ошибок {}", url, updated, failed)
|
||||
return updated, failed
|
||||
finally:
|
||||
await page.close()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user