This commit is contained in:
2026-04-30 18:54:24 +03:00
parent 88bf301b60
commit 87b692ba49
8 changed files with 1545 additions and 691 deletions

View File

@@ -20,6 +20,7 @@ from loguru import logger
from .state import StateDB
from .worker import download_manga, check_for_updates
from .exporter import patch_meta, MangaMeta
from .sources import registry, get_source_for_url, extract_domain
OUTPUT_DIR = Path("/app/output")
FRONTEND_DIR = Path("/app/frontend")
@@ -172,6 +173,16 @@ async def _queue_worker_loop():
@app.on_event("startup")
async def startup_event():
# Синхронизируем источники с кодом и мигрируем существующие манги
_db = StateDB()
try:
_db.sync_sources(registry)
migrated = _db.migrate_manga_sources()
if migrated:
logger.info("Авто-миграция: проставлен source_id для {} манг", migrated)
finally:
_db.close()
asyncio.create_task(queue_worker())
asyncio.create_task(update_scheduler())
# Восстанавливаем очередь из БД (незавершённые задачи)
@@ -365,6 +376,16 @@ def _enrich_manga(m: dict, db: StateDB) -> dict:
AND pages_total > 0 AND pages_done < pages_total""",
(m["url"],)
).fetchone()[0]
# Источник
source_info = None
if m.get("source_id"):
src = db.get_source_by_id(m["source_id"])
if src:
source_info = {"id": src["id"], "slug": src["slug"], "display_name": src["display_name"]}
else:
source_info = {"id": m["source_id"], "slug": "unknown", "display_name": "Источник недоступен"}
return {
**m,
"chapters_done": ch_done_count,
@@ -375,6 +396,7 @@ def _enrich_manga(m: dict, db: StateDB) -> dict:
"errors_count": ch_failed + ch_partial,
"started_at": m.get("started_at"),
"finished_at": m.get("finished_at"),
"source": source_info,
}
@@ -454,6 +476,7 @@ def _manga_detail(manga: dict, db: StateDB) -> dict:
class AddMangaRequest(BaseModel):
urls: List[str]
format: str = "cbz"
source_id: Optional[int] = None # явный выбор источника (для неизвестных доменов)
# ── Auth API ─────────────────────────────────
@@ -536,7 +559,24 @@ async def add_to_queue(body: AddMangaRequest):
url = url.strip()
if not url:
continue
is_new = db.add_manga(url, body.format)
# Определяем source_id: явный из запроса или авто по домену
source_id = body.source_id
if source_id is None:
domain = extract_domain(url)
source_row = db.get_source_by_domain(domain)
if source_row:
source_id = source_row["id"]
# Если источник указан явно — привязываем домен к нему
if body.source_id is not None:
domain = extract_domain(url)
existing = db.get_source_by_domain(domain)
if existing and existing["id"] != body.source_id:
db.remove_domain(existing["id"], domain)
db.add_domain(body.source_id, domain)
is_new = db.add_manga(url, body.format, source_id=source_id)
if is_new:
await download_queue.put({"url": url, "fmt": body.format})
added.append(url)
@@ -544,9 +584,9 @@ async def add_to_queue(body: AddMangaRequest):
"type": "manga_queued",
"url": url,
"format": body.format,
"source_id": source_id,
})
await _broadcast_queue_positions()
# Запускаем фоновую задачу предпросмотра (без Chromium — быстро)
asyncio.create_task(_fetch_preview(url))
else:
skipped.append(url)
@@ -559,15 +599,27 @@ async def _fetch_preview(url: str):
"""Быстро получает название и количество глав сразу после добавления."""
try:
from .browser import BrowserManager
from .scraper import get_manga_info
async with BrowserManager(headless=True) as bm:
_, page = await bm.new_page()
manga = await get_manga_info(page, url)
if not manga:
return
db = StateDB()
try:
db.update_manga_info(
source = get_source_for_url(url, db)
if source is None:
manga_row = db.get_manga(url)
if manga_row and manga_row.get("source_id"):
source = registry.get_by_db_id(manga_row["source_id"], db)
finally:
db.close()
if source is None:
return
async with BrowserManager(headless=True) as bm:
_, page = await bm.new_page()
manga = await source.get_manga_info(page, url)
if not manga:
return
db2 = StateDB()
try:
db2.update_manga_info(
url,
title=manga.title_ru or manga.title,
chapters_total=len(manga.chapters),
@@ -576,7 +628,7 @@ async def _fetch_preview(url: str):
pub_status=manga.pub_status,
)
finally:
db.close()
db2.close()
await ws_manager.broadcast({
"type": "manga_preview",
"url": url,
@@ -996,6 +1048,151 @@ async def delete_manga(url: str, delete_files: bool = False):
db.close()
# ── Sources API ───────────────────────────────
class DomainAdd(BaseModel):
domain: str
class SwitchSourceRequest(BaseModel):
url: str
source_id: int
@app.get("/api/sources")
async def list_sources():
"""Список всех источников с доменами."""
db = StateDB()
try:
return db.get_all_sources()
finally:
db.close()
@app.get("/api/resolve-source")
async def resolve_source(url: str):
"""Определить источник по URL. Возвращает {id, slug, display_name} или null."""
db = StateDB()
try:
domain = extract_domain(url)
row = db.get_source_by_domain(domain)
if not row:
return {"source": None, "domain": domain}
return {
"source": {
"id": row["id"],
"slug": row["slug"],
"display_name": row["display_name"],
},
"domain": domain,
}
finally:
db.close()
@app.post("/api/sources/{source_id}/domains")
async def add_domain(source_id: int, body: DomainAdd):
"""Добавить домен к источнику."""
db = StateDB()
try:
source = db.get_source_by_id(source_id)
if not source:
raise HTTPException(status_code=404, detail="Источник не найден")
domain = body.domain.lower().strip()
if not domain:
raise HTTPException(status_code=400, detail="Домен не может быть пустым")
# Проверяем не занят ли домен другим источником
existing = db.get_source_by_domain(domain)
if existing and existing["id"] != source_id:
raise HTTPException(
status_code=409,
detail=f"Домен уже привязан к источнику «{existing['display_name']}»"
)
ok = db.add_domain(source_id, domain)
if not ok:
raise HTTPException(status_code=409, detail="Домен уже существует")
await ws_manager.broadcast({
"type": "source_domain_added",
"source_id": source_id,
"domain": domain,
})
return {"ok": True, "domain": domain}
finally:
db.close()
@app.delete("/api/sources/{source_id}/domains/{domain:path}")
async def remove_domain(source_id: int, domain: str):
"""Удалить домен у источника."""
db = StateDB()
try:
source = db.get_source_by_id(source_id)
if not source:
raise HTTPException(status_code=404, detail="Источник не найден")
ok = db.remove_domain(source_id, domain)
if not ok:
raise HTTPException(status_code=404, detail="Домен не найден")
await ws_manager.broadcast({
"type": "source_domain_removed",
"source_id": source_id,
"domain": domain,
})
return {"ok": True}
finally:
db.close()
@app.post("/api/mangas/switch-source")
async def switch_manga_source(body: SwitchSourceRequest):
"""Сменить источник у манги + перепривязать домен."""
db = StateDB()
try:
manga = db.get_manga(body.url)
if not manga:
raise HTTPException(status_code=404, detail="Манга не найдена")
if manga["status"] == "downloading" and body.url in active_tasks:
raise HTTPException(status_code=400, detail="Нельзя сменить источник во время загрузки")
new_source = db.get_source_by_id(body.source_id)
if not new_source:
raise HTTPException(status_code=404, detail="Источник не найден")
old_source_id = manga.get("source_id")
domain = extract_domain(body.url)
# Перепривязываем домен
if domain:
existing_domain = db.get_source_by_domain(domain)
if existing_domain and existing_domain["id"] != body.source_id:
db.remove_domain(existing_domain["id"], domain)
db.add_domain(body.source_id, domain)
# Меняем источник у манги
db.set_manga_source(body.url, body.source_id)
# Сбрасываем failed/partial главы → pending
reset_count = db.reset_failed_chapters(body.url)
await ws_manager.broadcast({
"type": "source_switched",
"url": body.url,
"old_source_id": old_source_id,
"new_source_id": body.source_id,
"new_source_name": new_source["display_name"],
"domain_rebound": bool(domain),
"chapters_reset": reset_count,
})
return {
"ok": True,
"source_id": body.source_id,
"source_name": new_source["display_name"],
"chapters_reset": reset_count,
}
finally:
db.close()
@app.get("/api/stats")
async def global_stats():
db = StateDB()