mangalib
This commit is contained in:
39
src/api.py
39
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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Модели данных (общие для всех источников)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
27
src/state.py
27
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user