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)