mangalib
This commit is contained in:
@@ -106,6 +106,9 @@
|
|||||||
<!-- Stats Row -->
|
<!-- Stats Row -->
|
||||||
<div id="stats-row" class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6"></div>
|
<div id="stats-row" class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6"></div>
|
||||||
|
|
||||||
|
<!-- Auth Warnings -->
|
||||||
|
<div id="auth-warnings" class="hidden mb-4 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
<!-- Add Manga Panel -->
|
<!-- Add Manga Panel -->
|
||||||
<div class="card rounded-xl p-5 mb-6">
|
<div class="card rounded-xl p-5 mb-6">
|
||||||
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Добавить мангу</h2>
|
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Добавить мангу</h2>
|
||||||
@@ -157,6 +160,7 @@
|
|||||||
<button onclick="filterMangas('done')" id="filter-done" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Готово</button>
|
<button onclick="filterMangas('done')" id="filter-done" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Готово</button>
|
||||||
<button onclick="filterMangas('failed')" id="filter-failed" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Ошибки</button>
|
<button onclick="filterMangas('failed')" id="filter-failed" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Ошибки</button>
|
||||||
<button onclick="filterMangas('stopped')" id="filter-stopped" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Остановлены</button>
|
<button onclick="filterMangas('stopped')" id="filter-stopped" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Остановлены</button>
|
||||||
|
<button onclick="filterMangas('ongoing')" id="filter-ongoing" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">🔄 Продолжаются</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -378,6 +382,7 @@ const state = {
|
|||||||
filter: 'all',
|
filter: 'all',
|
||||||
sources: [], // [{id, slug, display_name, domains}]
|
sources: [], // [{id, slug, display_name, domains}]
|
||||||
currentUser: null, // {id, username, role}
|
currentUser: null, // {id, username, role}
|
||||||
|
authWarnings: {}, // source_slug → {source_slug, source_name}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Auth ─────────────────────────────────────
|
// ── Auth ─────────────────────────────────────
|
||||||
@@ -523,6 +528,7 @@ function handleEvent(msg) {
|
|||||||
case 'snapshot':
|
case 'snapshot':
|
||||||
msg.mangas.forEach(m => { state.mangas[m.url] = m; });
|
msg.mangas.forEach(m => { state.mangas[m.url] = m; });
|
||||||
renderList();
|
renderList();
|
||||||
|
renderAuthWarnings();
|
||||||
loadStats();
|
loadStats();
|
||||||
// Дополнительно запрашиваем свежие данные с сервера — на случай если
|
// Дополнительно запрашиваем свежие данные с сервера — на случай если
|
||||||
// пока WS был отключён, статусы изменились и события были потеряны
|
// пока WS был отключён, статусы изменились и события были потеряны
|
||||||
@@ -750,6 +756,29 @@ function handleEvent(msg) {
|
|||||||
updateMangaRow(msg.url);
|
updateMangaRow(msg.url);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'auth_required':
|
||||||
|
if(state.mangas[msg.url]) {
|
||||||
|
state.mangas[msg.url].status = 'stopped';
|
||||||
|
state.mangas[msg.url].last_error = `auth_required:${msg.source_slug}`;
|
||||||
|
if(msg.finished_at) state.mangas[msg.url].finished_at = msg.finished_at;
|
||||||
|
}
|
||||||
|
state.authWarnings[msg.source_slug] = {source_slug: msg.source_slug, source_name: msg.source_slug};
|
||||||
|
renderList();
|
||||||
|
renderAuthWarnings();
|
||||||
|
loadStats();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'source_settings_updated':
|
||||||
|
loadSources().then(() => {
|
||||||
|
// Clear warnings for sources that now have a token
|
||||||
|
state.sources.forEach(s => {
|
||||||
|
if(s.has_token) delete state.authWarnings[s.slug];
|
||||||
|
});
|
||||||
|
// Refresh mangas to get cleared last_error values
|
||||||
|
_refreshMangaList().then(() => renderAuthWarnings());
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1300,6 +1329,21 @@ function renderSources() {
|
|||||||
</button>
|
</button>
|
||||||
</span>` : ''}
|
</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${s.supports_auth_token && isAdmin() ? `
|
||||||
|
<div class="mt-3 pt-3" style="border-top:1px solid #1e293b">
|
||||||
|
<div class="text-xs text-gray-400 mb-2">Токен авторизации (Bearer JWT)</div>
|
||||||
|
${s.has_token ? `<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-xs text-green-400">✓ Токен сохранён</span>
|
||||||
|
<button onclick="clearSourceToken(${s.id})" class="text-xs px-2 py-1 rounded" style="background:#1e293b;color:#ef4444;border:1px solid #374151">Удалить</button>
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="token-input-${s.id}" type="password" placeholder="${s.has_token ? 'Введите новый токен для замены' : 'eyJ0eXAiOiJKV1Qi...'}"
|
||||||
|
class="text-xs px-2 py-1 rounded flex-1" style="background:#0f1117;border:1px solid #334155;color:#e2e8f0;min-width:0"
|
||||||
|
onkeydown="if(event.key==='Enter') saveSourceToken(${s.id})">
|
||||||
|
<button onclick="saveSourceToken(${s.id})" class="text-xs px-3 py-1 rounded font-semibold flex-shrink-0" style="background:#4f46e5;color:white">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
@@ -1358,6 +1402,78 @@ async function removeDomain(sourceId, domain) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveSourceToken(sourceId) {
|
||||||
|
const input = document.getElementById('token-input-' + sourceId);
|
||||||
|
if(!input) return;
|
||||||
|
const token = input.value.trim();
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/sources/${sourceId}/settings`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({settings: {auth_token: token}}),
|
||||||
|
});
|
||||||
|
if(!r.ok) {
|
||||||
|
const err = await r.json();
|
||||||
|
_showNotification('Ошибка: ' + (err.detail || 'неизвестная ошибка'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
_showNotification('Токен сохранён', 'success');
|
||||||
|
await loadSources();
|
||||||
|
} catch(e) {
|
||||||
|
_showNotification('Ошибка: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSourceToken(sourceId) {
|
||||||
|
if(!confirm('Удалить токен авторизации?')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/sources/${sourceId}/settings`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({settings: {auth_token: ''}}),
|
||||||
|
});
|
||||||
|
if(r.ok) {
|
||||||
|
_showNotification('Токен удалён', 'success');
|
||||||
|
await loadSources();
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuthWarnings() {
|
||||||
|
const container = document.getElementById('auth-warnings');
|
||||||
|
if(!container) return;
|
||||||
|
// Collect unique source slugs with unresolved auth errors from current manga state
|
||||||
|
const slugs = {};
|
||||||
|
Object.values(state.mangas).forEach(m => {
|
||||||
|
const err = m.last_error || '';
|
||||||
|
if(err.startsWith('auth_required:')) {
|
||||||
|
const slug = err.slice('auth_required:'.length);
|
||||||
|
if(!slugs[slug]) {
|
||||||
|
const src = state.sources.find(s => s.slug === slug);
|
||||||
|
slugs[slug] = src ? src.display_name : slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Also include warnings from state.authWarnings (received via WS before manga list refresh)
|
||||||
|
Object.entries(state.authWarnings).forEach(([slug, info]) => {
|
||||||
|
if(!slugs[slug]) slugs[slug] = info.source_name || slug;
|
||||||
|
});
|
||||||
|
const entries = Object.entries(slugs);
|
||||||
|
if(!entries.length) {
|
||||||
|
container.classList.add('hidden');
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
container.innerHTML = entries.map(([slug, name]) => `
|
||||||
|
<div class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm" style="background:#431407;border:1px solid #7c2d12;color:#fed7aa">
|
||||||
|
<span style="font-size:1.1rem">⚠</span>
|
||||||
|
<span>Токен авторизации для <strong>${escHtml(name)}</strong> устарел или отсутствует. Обновите токен в <button onclick="switchTab('settings')" class="underline hover:text-orange-200">Настройках</button>.</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Switch Source Modal ───────────────────────
|
// ── Switch Source Modal ───────────────────────
|
||||||
let _switchSourceUrl = null;
|
let _switchSourceUrl = null;
|
||||||
|
|
||||||
@@ -1829,7 +1945,11 @@ function _rowAuto(m) {
|
|||||||
|
|
||||||
function _sortedMangas() {
|
function _sortedMangas() {
|
||||||
let mangas = Object.values(state.mangas);
|
let mangas = Object.values(state.mangas);
|
||||||
if(state.filter !== 'all') mangas = mangas.filter(m => m.status === state.filter);
|
if(state.filter === 'ongoing') {
|
||||||
|
mangas = mangas.filter(m => m.pub_status === 'ongoing');
|
||||||
|
} else if(state.filter !== 'all') {
|
||||||
|
mangas = mangas.filter(m => m.status === state.filter);
|
||||||
|
}
|
||||||
const order = {downloading: 0, queued: 1, stopped: 2, failed: 3, done: 4};
|
const order = {downloading: 0, queued: 1, stopped: 2, failed: 3, done: 4};
|
||||||
mangas.sort((a, b) => {
|
mangas.sort((a, b) => {
|
||||||
const oa = order[a.status] ?? 2, ob = order[b.status] ?? 2;
|
const oa = order[a.status] ?? 2, ob = order[b.status] ?? 2;
|
||||||
@@ -2310,6 +2430,7 @@ async function _refreshMangaList() {
|
|||||||
const mangas = await r.json();
|
const mangas = await r.json();
|
||||||
mangas.forEach(m => { state.mangas[m.url] = m; });
|
mangas.forEach(m => { state.mangas[m.url] = m; });
|
||||||
renderList();
|
renderList();
|
||||||
|
renderAuthWarnings();
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
src/api.py
39
src/api.py
@@ -3,6 +3,7 @@ FastAPI веб-сервер: REST API + WebSocket для мониторинга
|
|||||||
Многопользовательская система с ролями admin / user.
|
Многопользовательская система с ролями admin / user.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -846,11 +847,20 @@ class DomainAdd(BaseModel):
|
|||||||
class SwitchSourceRequest(BaseModel):
|
class SwitchSourceRequest(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
source_id: int
|
source_id: int
|
||||||
|
class UpdateSourceSettingsRequest(BaseModel):
|
||||||
|
settings: dict
|
||||||
@app.get("/api/sources")
|
@app.get("/api/sources")
|
||||||
async def list_sources(_: dict = Depends(get_current_user)):
|
async def list_sources(_: dict = Depends(get_current_user)):
|
||||||
db = StateDB()
|
db = StateDB()
|
||||||
try:
|
try:
|
||||||
return db.get_all_sources()
|
sources = db.get_all_sources()
|
||||||
|
for s in sources:
|
||||||
|
src_obj = registry.get_by_slug(s["slug"])
|
||||||
|
s["supports_auth_token"] = bool(src_obj and getattr(src_obj, "supports_auth_token", False))
|
||||||
|
settings = s.get("settings") or {}
|
||||||
|
s["has_token"] = bool(settings.get("auth_token"))
|
||||||
|
settings.pop("auth_token", None) # never send raw token to frontend
|
||||||
|
return sources
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@app.get("/api/resolve-source")
|
@app.get("/api/resolve-source")
|
||||||
@@ -902,6 +912,33 @@ async def remove_domain(source_id: int, domain: str, _: dict = Depends(require_a
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
@app.patch("/api/sources/{source_id}/settings")
|
||||||
|
async def update_source_settings(source_id: int, body: UpdateSourceSettingsRequest,
|
||||||
|
_: dict = Depends(require_admin)):
|
||||||
|
db = StateDB()
|
||||||
|
try:
|
||||||
|
source = db.get_source_by_id(source_id)
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||||
|
existing_raw = source.get("settings") or "{}"
|
||||||
|
try:
|
||||||
|
existing = json.loads(existing_raw) if isinstance(existing_raw, str) else (existing_raw or {})
|
||||||
|
except Exception:
|
||||||
|
existing = {}
|
||||||
|
existing.update(body.settings)
|
||||||
|
# Remove empty/null auth_token to keep settings clean
|
||||||
|
if "auth_token" in existing and not existing["auth_token"]:
|
||||||
|
del existing["auth_token"]
|
||||||
|
db.update_source_settings(source_id, existing)
|
||||||
|
# If auth_token was saved, clear auth errors on mangas from this source
|
||||||
|
if body.settings.get("auth_token"):
|
||||||
|
for m in db.get_mangas_by_source(source_id):
|
||||||
|
if (m.get("last_error") or "").startswith("auth_required:"):
|
||||||
|
db.set_manga_last_error(m["url"], None)
|
||||||
|
await ws_manager.broadcast({"type": "source_settings_updated", "source_id": source_id})
|
||||||
|
return {"ok": True}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@app.post("/api/mangas/switch-source")
|
@app.post("/api/mangas/switch-source")
|
||||||
async def switch_manga_source(body: SwitchSourceRequest, _: dict = Depends(require_admin)):
|
async def switch_manga_source(body: SwitchSourceRequest, _: dict = Depends(require_admin)):
|
||||||
db = StateDB()
|
db = StateDB()
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ from typing import Optional, Protocol, runtime_checkable
|
|||||||
from playwright.async_api import Page
|
from playwright.async_api import Page
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRequiredError(Exception):
|
||||||
|
"""Источник требует авторизации — токен не задан или просрочен."""
|
||||||
|
def __init__(self, source_slug: str):
|
||||||
|
self.source_slug = source_slug
|
||||||
|
super().__init__(f"Auth required for source: {source_slug}")
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# Модели данных (общие для всех источников)
|
# Модели данных (общие для всех источников)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -20,16 +20,20 @@ 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
|
||||||
|
|
||||||
from .base import Chapter, MangaInfo
|
from .base import Chapter, MangaInfo, AuthRequiredError
|
||||||
|
|
||||||
|
|
||||||
class MangalibSource:
|
class MangalibSource:
|
||||||
slug = "mangalib"
|
slug = "mangalib"
|
||||||
display_name = "MangaLib"
|
display_name = "MangaLib"
|
||||||
|
supports_auth_token = True
|
||||||
|
|
||||||
# CDN-домены для изображений глав (актуальные)
|
# CDN-домены для изображений глав (актуальные)
|
||||||
cdn_patterns = ["mixlib.me", "imglib.info", "imglib", "imgslib"]
|
cdn_patterns = ["mixlib.me", "imglib.info", "imglib", "imgslib"]
|
||||||
|
|
||||||
|
# Токен авторизации — устанавливается воркером из настроек источника в БД
|
||||||
|
auth_token: Optional[str] = None
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# Страница манги — список глав
|
# Страница манги — список глав
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
@@ -44,6 +48,7 @@ class MangalibSource:
|
|||||||
# Слушаем API-ответы до навигации
|
# Слушаем API-ответы до навигации
|
||||||
chapters_api_data: list = []
|
chapters_api_data: list = []
|
||||||
manga_api_data: dict = {}
|
manga_api_data: dict = {}
|
||||||
|
chapters_auth_error: list = []
|
||||||
lock = asyncio.Lock()
|
lock = asyncio.Lock()
|
||||||
|
|
||||||
async def on_response(resp):
|
async def on_response(resp):
|
||||||
@@ -53,6 +58,9 @@ class MangalibSource:
|
|||||||
try:
|
try:
|
||||||
# api.cdnlibs.org/api/manga/{slug}/chapters (без query-параметров)
|
# api.cdnlibs.org/api/manga/{slug}/chapters (без query-параметров)
|
||||||
if re.search(r"/chapters$", resp_url):
|
if re.search(r"/chapters$", resp_url):
|
||||||
|
if resp.status in (401, 403):
|
||||||
|
chapters_auth_error.append(True)
|
||||||
|
return
|
||||||
body = await resp.body()
|
body = await resp.body()
|
||||||
data = _json.loads(body)
|
data = _json.loads(body)
|
||||||
raw = data.get("data", [])
|
raw = data.get("data", [])
|
||||||
@@ -73,6 +81,8 @@ class MangalibSource:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("API parse error: {}", e)
|
logger.debug("API parse error: {}", e)
|
||||||
|
|
||||||
|
if self.auth_token:
|
||||||
|
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
|
||||||
page.on("response", on_response)
|
page.on("response", on_response)
|
||||||
|
|
||||||
ok = await _navigate(page, chapters_url)
|
ok = await _navigate(page, chapters_url)
|
||||||
@@ -89,6 +99,9 @@ class MangalibSource:
|
|||||||
|
|
||||||
page.remove_listener("response", on_response)
|
page.remove_listener("response", on_response)
|
||||||
|
|
||||||
|
if chapters_auth_error and not chapters_api_data:
|
||||||
|
raise AuthRequiredError(self.slug)
|
||||||
|
|
||||||
# Извлекаем pub_status из API манги (надёжнее DOM)
|
# Извлекаем pub_status из API манги (надёжнее DOM)
|
||||||
async with lock:
|
async with lock:
|
||||||
manga_meta = dict(manga_api_data)
|
manga_meta = dict(manga_api_data)
|
||||||
@@ -172,12 +185,16 @@ class MangalibSource:
|
|||||||
|
|
||||||
chapter_api: dict = {}
|
chapter_api: dict = {}
|
||||||
image_servers: list = []
|
image_servers: list = []
|
||||||
|
chapter_auth_error: list = []
|
||||||
lock = asyncio.Lock()
|
lock = asyncio.Lock()
|
||||||
|
|
||||||
async def on_response(resp):
|
async def on_response(resp):
|
||||||
resp_url = resp.url
|
resp_url = resp.url
|
||||||
try:
|
try:
|
||||||
if "api.cdnlibs.org" in resp_url and "/chapter?" in resp_url:
|
if "api.cdnlibs.org" in resp_url and "/chapter?" in resp_url:
|
||||||
|
if resp.status in (401, 403):
|
||||||
|
chapter_auth_error.append(True)
|
||||||
|
return
|
||||||
body = await resp.body()
|
body = await resp.body()
|
||||||
data = _json.loads(body)
|
data = _json.loads(body)
|
||||||
async with lock:
|
async with lock:
|
||||||
@@ -193,6 +210,8 @@ class MangalibSource:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if self.auth_token:
|
||||||
|
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
|
||||||
page.on("response", on_response)
|
page.on("response", on_response)
|
||||||
|
|
||||||
referer = manga_url or referer_origin
|
referer = manga_url or referer_origin
|
||||||
@@ -211,6 +230,9 @@ class MangalibSource:
|
|||||||
|
|
||||||
page.remove_listener("response", on_response)
|
page.remove_listener("response", on_response)
|
||||||
|
|
||||||
|
if chapter_auth_error and not chapter_api.get("pages"):
|
||||||
|
raise AuthRequiredError(self.slug)
|
||||||
|
|
||||||
async with lock:
|
async with lock:
|
||||||
pages_info = list(chapter_api.get("pages", []))
|
pages_info = list(chapter_api.get("pages", []))
|
||||||
servers_list = list(image_servers)
|
servers_list = list(image_servers)
|
||||||
|
|||||||
27
src/state.py
27
src/state.py
@@ -72,7 +72,11 @@ class StateDB:
|
|||||||
added_at TEXT,
|
added_at TEXT,
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
started_at TEXT,
|
started_at TEXT,
|
||||||
finished_at TEXT
|
finished_at TEXT,
|
||||||
|
folder_name TEXT,
|
||||||
|
source_id INTEGER REFERENCES sources(id),
|
||||||
|
added_by INTEGER REFERENCES users(id),
|
||||||
|
last_error TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
self.conn.execute("""
|
self.conn.execute("""
|
||||||
@@ -154,6 +158,7 @@ class StateDB:
|
|||||||
("mangas", "folder_name", "TEXT"),
|
("mangas", "folder_name", "TEXT"),
|
||||||
("mangas", "source_id", "INTEGER REFERENCES sources(id)"),
|
("mangas", "source_id", "INTEGER REFERENCES sources(id)"),
|
||||||
("mangas", "added_by", "INTEGER REFERENCES users(id)"),
|
("mangas", "added_by", "INTEGER REFERENCES users(id)"),
|
||||||
|
("mangas", "last_error", "TEXT"),
|
||||||
("users", "is_env_admin", "INTEGER NOT NULL DEFAULT 0"),
|
("users", "is_env_admin", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
]
|
]
|
||||||
for table, col, typedef in migrations:
|
for table, col, typedef in migrations:
|
||||||
@@ -416,6 +421,26 @@ class StateDB:
|
|||||||
""", (status, _now(), url))
|
""", (status, _now(), url))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
def set_manga_last_error(self, manga_url: str, error: Optional[str]) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE mangas SET last_error=?, updated_at=? WHERE url=?",
|
||||||
|
(error, _now(), manga_url)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_mangas_by_source(self, source_id: int) -> list[dict]:
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"SELECT url, last_error FROM mangas WHERE source_id=?", (source_id,)
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def update_source_settings(self, source_id: int, settings: dict) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE sources SET settings=? WHERE id=?",
|
||||||
|
(json.dumps(settings), source_id)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
def mark_started(self, url: str) -> str:
|
def mark_started(self, url: str) -> str:
|
||||||
"""Записывает время начала загрузки. Возвращает timestamp."""
|
"""Записывает время начала загрузки. Возвращает timestamp."""
|
||||||
ts = _now()
|
ts = _now()
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ 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
|
import json as _json
|
||||||
|
from .sources.base import Chapter, MangaInfo, AuthRequiredError
|
||||||
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
|
from .utils import safe_name, safe_chapter_name
|
||||||
@@ -66,10 +67,30 @@ async def download_manga(
|
|||||||
"error": "Источник не определён. Выберите источник в настройках манги."})
|
"error": "Источник не определён. Выберите источник в настройках манги."})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Inject auth token from source DB settings
|
||||||
|
if hasattr(source, "auth_token"):
|
||||||
|
_src_row = await db_call(db.get_source_by_slug, source.slug)
|
||||||
|
if _src_row:
|
||||||
|
_settings_raw = _src_row.get("settings") or "{}"
|
||||||
|
try:
|
||||||
|
_settings = _json.loads(_settings_raw) if isinstance(_settings_raw, str) else (_settings_raw or {})
|
||||||
|
except Exception:
|
||||||
|
_settings = {}
|
||||||
|
source.auth_token = _settings.get("auth_token") or None
|
||||||
|
|
||||||
async with BrowserManager(headless=True) as bm:
|
async with BrowserManager(headless=True) as bm:
|
||||||
ctx, info_page = await bm.new_page()
|
ctx, info_page = await bm.new_page()
|
||||||
|
|
||||||
|
try:
|
||||||
manga = await source.get_manga_info(info_page, url)
|
manga = await source.get_manga_info(info_page, url)
|
||||||
|
except AuthRequiredError as e:
|
||||||
|
await info_page.close()
|
||||||
|
await db_call(db.update_manga_status, url, "stopped")
|
||||||
|
await db_call(db.set_manga_last_error, url, f"auth_required:{e.source_slug}")
|
||||||
|
finished_ts = await db_call(db.mark_finished, url)
|
||||||
|
await emit({"type": "auth_required", "url": url,
|
||||||
|
"source_slug": e.source_slug, "finished_at": finished_ts})
|
||||||
|
return
|
||||||
await info_page.close()
|
await info_page.close()
|
||||||
|
|
||||||
if not manga:
|
if not manga:
|
||||||
@@ -267,6 +288,8 @@ async def download_manga(
|
|||||||
"chapters_total": len(manga.chapters),
|
"chapters_total": len(manga.chapters),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
except AuthRequiredError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Необработанное исключение в Т{} Гл.{} '{}' | {}: {}",
|
"Необработанное исключение в Т{} Гл.{} '{}' | {}: {}",
|
||||||
@@ -282,14 +305,25 @@ async def download_manga(
|
|||||||
tasks = [process_chapter(ch) for ch in to_download]
|
tasks = [process_chapter(ch) for ch in to_download]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
# Логируем неожиданные исключения из gather
|
# Логируем неожиданные исключения из gather; обнаруживаем auth ошибки
|
||||||
|
auth_slug = None
|
||||||
for ch, res in zip(to_download, results):
|
for ch, res in zip(to_download, results):
|
||||||
if isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
|
if isinstance(res, AuthRequiredError):
|
||||||
|
auth_slug = res.source_slug
|
||||||
|
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"gather: необработанное исключение Т{} Гл.{} '{}': {}",
|
"gather: необработанное исключение Т{} Гл.{} '{}': {}",
|
||||||
ch.volume, ch.number, ch.title, res,
|
ch.volume, ch.number, ch.title, res,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if auth_slug:
|
||||||
|
await db_call(db.update_manga_status, url, "stopped")
|
||||||
|
await db_call(db.set_manga_last_error, url, f"auth_required:{auth_slug}")
|
||||||
|
finished_ts = await db_call(db.mark_finished, url)
|
||||||
|
await emit({"type": "auth_required", "url": url,
|
||||||
|
"source_slug": auth_slug, "finished_at": finished_ts})
|
||||||
|
return
|
||||||
|
|
||||||
real_done = await db_call(db.sync_chapters_done, url)
|
real_done = await db_call(db.sync_chapters_done, url)
|
||||||
await db_call(db.update_manga_status, url, "done")
|
await db_call(db.update_manga_status, url, "done")
|
||||||
finished_ts = await db_call(db.mark_finished, url)
|
finished_ts = await db_call(db.mark_finished, url)
|
||||||
|
|||||||
Reference in New Issue
Block a user