From 07bc7ef1e00d1a0b902a3fca4afd54cfee6c02fc Mon Sep 17 00:00:00 2001 From: StenFredd Date: Sat, 2 May 2026 22:31:33 +0300 Subject: [PATCH] mangalib --- frontend/index.html | 123 +++++++++++++++++++++++++++++++++++++++- src/api.py | 39 ++++++++++++- src/sources/base.py | 7 +++ src/sources/mangalib.py | 24 +++++++- src/state.py | 27 ++++++++- src/worker.py | 42 ++++++++++++-- 6 files changed, 254 insertions(+), 8 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index a987af8..a8e479b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -106,6 +106,9 @@
+ + +

Добавить мангу

@@ -157,6 +160,7 @@ +
@@ -378,6 +382,7 @@ const state = { filter: 'all', sources: [], // [{id, slug, display_name, domains}] currentUser: null, // {id, username, role} + authWarnings: {}, // source_slug → {source_slug, source_name} }; // ── Auth ───────────────────────────────────── @@ -523,6 +528,7 @@ function handleEvent(msg) { case 'snapshot': msg.mangas.forEach(m => { state.mangas[m.url] = m; }); renderList(); + renderAuthWarnings(); loadStats(); // Дополнительно запрашиваем свежие данные с сервера — на случай если // пока WS был отключён, статусы изменились и события были потеряны @@ -750,6 +756,29 @@ function handleEvent(msg) { updateMangaRow(msg.url); } 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() { ` : ''} + ${s.supports_auth_token && isAdmin() ? ` +
+
Токен авторизации (Bearer JWT)
+ ${s.has_token ? `
+ ✓ Токен сохранён + +
` : ''} +
+ + +
+
+ ` : ''} `).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]) => ` +
+ + Токен авторизации для ${escHtml(name)} устарел или отсутствует. Обновите токен в . +
+ `).join(''); +} + // ── Switch Source Modal ─────────────────────── let _switchSourceUrl = null; @@ -1829,7 +1945,11 @@ function _rowAuto(m) { function _sortedMangas() { 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}; mangas.sort((a, b) => { const oa = order[a.status] ?? 2, ob = order[b.status] ?? 2; @@ -2310,6 +2430,7 @@ async function _refreshMangaList() { const mangas = await r.json(); mangas.forEach(m => { state.mangas[m.url] = m; }); renderList(); + renderAuthWarnings(); } catch(e) {} } diff --git a/src/api.py b/src/api.py index 7db2b33..793c294 100644 --- a/src/api.py +++ b/src/api.py @@ -3,6 +3,7 @@ FastAPI веб-сервер: REST API + WebSocket для мониторинга Многопользовательская система с ролями admin / user. """ import asyncio +import json import os import shutil from datetime import datetime, timedelta, timezone @@ -846,11 +847,20 @@ class DomainAdd(BaseModel): class SwitchSourceRequest(BaseModel): url: str source_id: int +class UpdateSourceSettingsRequest(BaseModel): + settings: dict @app.get("/api/sources") async def list_sources(_: dict = Depends(get_current_user)): db = StateDB() 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: db.close() @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} finally: 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") async def switch_manga_source(body: SwitchSourceRequest, _: dict = Depends(require_admin)): db = StateDB() diff --git a/src/sources/base.py b/src/sources/base.py index 9438340..3fe1bbd 100644 --- a/src/sources/base.py +++ b/src/sources/base.py @@ -8,6 +8,13 @@ from typing import Optional, Protocol, runtime_checkable 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}") + + # ────────────────────────────────────────────── # Модели данных (общие для всех источников) # ────────────────────────────────────────────── diff --git a/src/sources/mangalib.py b/src/sources/mangalib.py index 02111a8..c9e08a2 100644 --- a/src/sources/mangalib.py +++ b/src/sources/mangalib.py @@ -20,16 +20,20 @@ from urllib.parse import urlparse from loguru import logger from playwright.async_api import Page -from .base import Chapter, MangaInfo +from .base import Chapter, MangaInfo, AuthRequiredError class MangalibSource: slug = "mangalib" display_name = "MangaLib" + supports_auth_token = True # CDN-домены для изображений глав (актуальные) cdn_patterns = ["mixlib.me", "imglib.info", "imglib", "imgslib"] + # Токен авторизации — устанавливается воркером из настроек источника в БД + auth_token: Optional[str] = None + # ────────────────────────────────────────────── # Страница манги — список глав # ────────────────────────────────────────────── @@ -44,6 +48,7 @@ class MangalibSource: # Слушаем API-ответы до навигации chapters_api_data: list = [] manga_api_data: dict = {} + chapters_auth_error: list = [] lock = asyncio.Lock() async def on_response(resp): @@ -53,6 +58,9 @@ class MangalibSource: try: # api.cdnlibs.org/api/manga/{slug}/chapters (без query-параметров) if re.search(r"/chapters$", resp_url): + if resp.status in (401, 403): + chapters_auth_error.append(True) + return body = await resp.body() data = _json.loads(body) raw = data.get("data", []) @@ -73,6 +81,8 @@ class MangalibSource: except Exception as 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) ok = await _navigate(page, chapters_url) @@ -89,6 +99,9 @@ class MangalibSource: page.remove_listener("response", on_response) + if chapters_auth_error and not chapters_api_data: + raise AuthRequiredError(self.slug) + # Извлекаем pub_status из API манги (надёжнее DOM) async with lock: manga_meta = dict(manga_api_data) @@ -172,12 +185,16 @@ class MangalibSource: chapter_api: dict = {} image_servers: list = [] + chapter_auth_error: list = [] lock = asyncio.Lock() async def on_response(resp): resp_url = resp.url try: 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() data = _json.loads(body) async with lock: @@ -193,6 +210,8 @@ class MangalibSource: 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 referer_origin @@ -211,6 +230,9 @@ class MangalibSource: page.remove_listener("response", on_response) + if chapter_auth_error and not chapter_api.get("pages"): + raise AuthRequiredError(self.slug) + async with lock: pages_info = list(chapter_api.get("pages", [])) servers_list = list(image_servers) diff --git a/src/state.py b/src/state.py index 2847638..2c3a603 100644 --- a/src/state.py +++ b/src/state.py @@ -72,7 +72,11 @@ class StateDB: added_at TEXT, updated_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(""" @@ -154,6 +158,7 @@ class StateDB: ("mangas", "folder_name", "TEXT"), ("mangas", "source_id", "INTEGER REFERENCES sources(id)"), ("mangas", "added_by", "INTEGER REFERENCES users(id)"), + ("mangas", "last_error", "TEXT"), ("users", "is_env_admin", "INTEGER NOT NULL DEFAULT 0"), ] for table, col, typedef in migrations: @@ -416,6 +421,26 @@ class StateDB: """, (status, _now(), url)) 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: """Записывает время начала загрузки. Возвращает timestamp.""" ts = _now() diff --git a/src/worker.py b/src/worker.py index bfe2ee5..aa63715 100644 --- a/src/worker.py +++ b/src/worker.py @@ -11,7 +11,8 @@ from loguru import logger from .browser import BrowserManager 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 .state import StateDB from .utils import safe_name, safe_chapter_name @@ -66,10 +67,30 @@ async def download_manga( "error": "Источник не определён. Выберите источник в настройках манги."}) 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: ctx, info_page = await bm.new_page() - manga = await source.get_manga_info(info_page, url) + try: + 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() if not manga: @@ -267,6 +288,8 @@ async def download_manga( "chapters_total": len(manga.chapters), }) + except AuthRequiredError: + raise except Exception as e: logger.exception( "Необработанное исключение в Т{} Гл.{} '{}' | {}: {}", @@ -282,14 +305,25 @@ async def download_manga( tasks = [process_chapter(ch) for ch in to_download] results = await asyncio.gather(*tasks, return_exceptions=True) - # Логируем неожиданные исключения из gather + # Логируем неожиданные исключения из gather; обнаруживаем auth ошибки + auth_slug = None 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( "gather: необработанное исключение Т{} Гл.{} '{}': {}", 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) await db_call(db.update_manga_status, url, "done") finished_ts = await db_call(db.mark_finished, url)