upd
This commit is contained in:
217
src/api.py
217
src/api.py
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user