Update metadata

This commit is contained in:
2026-05-03 15:18:09 +03:00
parent 93eff68b8d
commit 6c0958b92e
3 changed files with 265 additions and 99 deletions

View File

@@ -222,6 +222,19 @@
</div>
<div id="users-list" class="flex flex-col gap-2"></div>
</div>
<!-- Обновить все метаданные (только admin) -->
<div id="refresh-all-section" class="hidden px-5 py-4 border-t border-gray-800">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-1">Обновить все метаданные</h3>
<p class="text-xs text-gray-500 mb-3">Запускает браузер для каждой скачанной манги: обновляет обложку, синопсис, жанры и метаданные в файлах CBZ/PDF/EPUB.</p>
<div class="flex items-center gap-3 flex-wrap">
<button id="refresh-all-btn" onclick="refreshAllMeta()"
class="text-xs px-4 py-2 rounded-lg font-semibold text-white"
style="background:#312e81;border:1px solid #4338ca;color:#a78bfa">
🔄 Обновить все метаданные
</button>
<div id="refresh-all-status" class="text-xs text-gray-400 hidden"></div>
</div>
</div>
<!-- Смена своего пароля -->
<div id="chpwd-section" class="px-5 py-4 border-t border-gray-800">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Сменить пароль</h3>
@@ -703,6 +716,18 @@ function handleEvent(msg) {
_updateMetaBtn(msg.url, msg.failed === -1 ? 'error' : 'done');
break;
case 'refresh_all_started':
_handleRefreshAllStarted(msg);
break;
case 'refresh_all_progress':
_handleRefreshAllProgress(msg);
break;
case 'refresh_all_done':
_handleRefreshAllDone(msg);
break;
case 'manga_meta_updated':
if(state.mangas[msg.url]) {
state.mangas[msg.url].title = msg.title;
@@ -812,7 +837,7 @@ function switchTab(tab) {
document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas');
if(tab === 'history') loadHistory();
if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); }
if(tab === 'settings') { loadSources(); showUsersSection(); }
if(tab === 'settings') { loadSources(); showUsersSection(); showRefreshAllSection(); }
}
function updateNewsBadge() {
@@ -1132,6 +1157,11 @@ function showUsersSection() {
}
}
function showRefreshAllSection() {
const el = document.getElementById('refresh-all-section');
if(el) el.classList.toggle('hidden', !isAdmin());
}
async function loadUsers() {
if(!isAdmin()) return;
try {
@@ -1654,7 +1684,7 @@ function _updateMetaBtn(url, result) {
btn.style.color = '#4ade80';
btn.style.borderColor = '#166534';
setTimeout(() => {
btn.innerHTML = '🏷 Обновить метатеги';
btn.innerHTML = '🏷 Обновить метаданные';
btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81';
}, 2500);
@@ -1664,12 +1694,12 @@ function _updateMetaBtn(url, result) {
btn.style.color = '#f87171';
btn.style.borderColor = '#7f1d1d';
setTimeout(() => {
btn.innerHTML = '🏷 Обновить метатеги';
btn.innerHTML = '🏷 Обновить метаданные';
btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81';
}, 3000);
} else {
btn.innerHTML = '🏷 Обновить метатеги';
btn.innerHTML = '🏷 Обновить метаданные';
btn.disabled = false;
btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81';
@@ -1691,6 +1721,46 @@ async function refreshMetaModal(url) {
// Спиннер появится через WS meta_refresh_started, исчезнет через meta_refreshed
}
async function refreshAllMeta() {
const btn = document.getElementById('refresh-all-btn');
const status = document.getElementById('refresh-all-status');
const r = await fetch('/api/mangas/refresh_all_meta', {method:'POST'});
if(!r.ok) {
const err = await r.json().catch(() => ({}));
if(status) { status.textContent = err.detail || 'Ошибка запуска'; status.classList.remove('hidden'); status.style.color = '#f87171'; }
return;
}
if(btn) { btn.disabled = true; btn.textContent = '⏳ Запускаем...'; }
if(status) { status.textContent = 'Инициализация...'; status.classList.remove('hidden'); status.style.color = '#94a3b8'; }
}
function _handleRefreshAllStarted(data) {
const btn = document.getElementById('refresh-all-btn');
const status = document.getElementById('refresh-all-status');
if(btn) { btn.disabled = true; btn.textContent = '⏳ Обновляем...'; }
if(status) { status.textContent = `0 / ${data.total}`; status.classList.remove('hidden'); status.style.color = '#94a3b8'; }
}
function _handleRefreshAllProgress(data) {
const status = document.getElementById('refresh-all-status');
if(status) {
const title = data.title ? `${data.title}` : '';
status.textContent = `${data.done + 1} / ${data.total}${title}`;
status.style.color = '#94a3b8';
}
}
function _handleRefreshAllDone(data) {
const btn = document.getElementById('refresh-all-btn');
const status = document.getElementById('refresh-all-status');
if(btn) { btn.disabled = false; btn.textContent = '🔄 Обновить все метаданные'; }
if(status) {
status.textContent = `Готово: ${data.total} манг, обновлено файлов: ${data.total_updated}`;
status.style.color = '#4ade80';
status.classList.remove('hidden');
}
}
async function forceRedownload(url, closeModalAfter = false) {
if(!confirm('Скачать заново ВСЕ главы? Уже скачанные файлы будут перезаписаны.')) return;
const r = await fetch('/api/mangas/force_redownload?url='+encodeURIComponent(url), {method:'POST'});
@@ -2236,7 +2306,7 @@ function renderModalBody(data) {
<button id="modal-refresh-meta-btn" onclick="refreshMetaModal('${escHtml(data.url)}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
style="background:#1e1b4b;color:#a78bfa;border:1px solid #312e81">
🏷 Обновить метатеги
🏷 Обновить метаданные
</button>` : ''}
${data.status !== 'downloading' && data.status !== 'queued' && isAdmin() ? `
<button onclick="forceRedownloadModal('${escHtml(data.url)}')"

View File

@@ -18,7 +18,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from loguru import logger
from .state import StateDB
from .worker import download_manga, check_for_updates
from .worker import download_manga, check_for_updates, refresh_manga_metadata
from .browser import BrowserManager
from .exporter import patch_meta, MangaMeta
from .sources import registry, get_source_for_url, extract_domain
@@ -73,6 +73,7 @@ ws_manager = ConnectionManager()
# ── Очередь загрузки ─────────────────────────
download_queue: asyncio.Queue = asyncio.Queue()
active_tasks: dict = {}
_refresh_all_running: bool = False
async def _broadcast_queue_positions():
queue_list = list(download_queue._queue)
positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)}
@@ -693,106 +694,75 @@ async def refresh_meta(url: str, current_user: dict = Depends(get_current_user))
db.close()
asyncio.create_task(_do_refresh_meta(url))
return {"ok": True}
def _patch_meta_sync(manga: dict, chapters: list, chapters_total: int, pub_status: str) -> tuple[int, int]:
updated = failed = 0
url = manga["url"]
summary = manga.get("description") or ""
tags_raw = manga.get("tags") or ""
try:
tags_str = ", ".join(json.loads(tags_raw)) if tags_raw else ""
except Exception:
tags_str = ""
for ch in chapters:
for fmt_col in ("output_cbz", "output_pdf", "output_epub"):
fpath = ch.get(fmt_col)
if not fpath:
continue
p = Path(fpath)
if not p.exists():
continue
meta = MangaMeta(
series=manga.get("title_ru") or manga.get("title") or "",
series_full=manga.get("title_full") or "",
chapter_title=ch.get("title") or "",
number=float(ch.get("number") or 0),
volume=int(ch.get("volume") or 0),
chapters_total=chapters_total,
pub_status=pub_status,
source_url=url,
summary=summary,
tags=tags_str,
)
if patch_meta(p, meta):
updated += 1
else:
failed += 1
return updated, failed
def _refresh_cover_sync(manga: dict, manga_dir: Path) -> None:
"""Скачивает или обновляет обложку через urllib (синхронно, для asyncio.to_thread)."""
import urllib.request as _urllib_req
import re as _re
cover_url = manga.get("cover_url") or ""
if not cover_url:
return
# Определяем Referer по URL обложки (MangaLib CDN — cdnlibs / mangalib)
if any(pat in cover_url for pat in ("mangalib", "cdnlibs", "imglib")):
referer = "https://mangalib.me/"
else:
from urllib.parse import urlparse as _up
parsed = _up(manga.get("url") or "")
referer = f"{parsed.scheme}://{parsed.netloc}/" if parsed.netloc else "https://readmanga.ru/"
try:
req = _urllib_req.Request(cover_url, headers={
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124.0.0.0",
"Referer": referer,
"Accept": "image/png,image/jpeg,image/webp,image/*,*/*",
})
with _urllib_req.urlopen(req, timeout=30) as resp:
body = resp.read()
if len(body) < 500:
logger.warning("refresh_cover: слишком малый ответ ({} байт)", len(body))
return
m = _re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", cover_url, _re.IGNORECASE)
ext = ("." + (m.group(1).lower() if m else "jpg")).replace(".jpeg", ".jpg")
cover_path = manga_dir / f"cover{ext}"
cover_path.write_bytes(body)
logger.info("Обложка обновлена: {} ({} байт)", cover_path.name, len(body))
except Exception as e:
logger.warning("refresh_cover error {}: {}", cover_url, e)
async def _do_refresh_meta(url: str):
db = StateDB()
try:
manga = db.get_manga(url)
if not manga:
return
chapters = db.get_all_chapters(url)
chapters_total = len(chapters)
pub_status = manga.get("pub_status", "unknown") or "unknown"
finally:
db.close()
try:
await ws_manager.broadcast({"type": "meta_refresh_started", "url": url})
updated, failed = await asyncio.to_thread(_patch_meta_sync, manga, chapters, chapters_total, pub_status)
logger.info("refresh_meta {}: обновлено {}, ошибок {}", url, updated, failed)
# Обновляем обложку если у манги формат cbz
manga_fmt = manga.get("format", "cbz") or "cbz"
if manga_fmt in ("cbz", "all") and manga.get("cover_url"):
manga_dir = _manga_folder(manga)
if manga_dir.exists():
await asyncio.to_thread(_refresh_cover_sync, manga, manga_dir)
updated, failed = await refresh_manga_metadata(url)
await ws_manager.broadcast({"type": "meta_refreshed", "url": url,
"updated": updated, "failed": failed})
except Exception as e:
logger.error("_do_refresh_meta {}: {}", url, e)
await ws_manager.broadcast({"type": "meta_refreshed", "url": url, "updated": 0, "failed": -1})
@app.post("/api/mangas/refresh_all_meta")
async def refresh_all_meta_endpoint(current_user: dict = Depends(require_admin)):
global _refresh_all_running
if _refresh_all_running:
raise HTTPException(status_code=409, detail="Обновление метаданных уже выполняется")
asyncio.create_task(_do_refresh_all_meta())
return {"ok": True}
@app.get("/api/mangas/refresh_all_meta/status")
async def refresh_all_meta_status(current_user: dict = Depends(require_admin)):
return {"running": _refresh_all_running}
async def _do_refresh_all_meta():
global _refresh_all_running
_refresh_all_running = True
db = StateDB()
try:
mangas = db.get_all_mangas()
finally:
db.close()
done_mangas = [m for m in mangas if m["status"] == "done"]
total = len(done_mangas)
await ws_manager.broadcast({"type": "refresh_all_started", "total": total})
logger.info("refresh_all_meta: начало, всего манг: {}", total)
total_updated = total_failed = 0
try:
for i, manga in enumerate(done_mangas):
url = manga["url"]
await ws_manager.broadcast({
"type": "refresh_all_progress",
"done": i,
"total": total,
"url": url,
"title": manga.get("title_ru") or manga.get("title") or url,
})
try:
updated, failed = await refresh_manga_metadata(url)
total_updated += updated
total_failed += failed
except Exception as e:
logger.error("refresh_all_meta {}: {}", url, e)
await ws_manager.broadcast({
"type": "refresh_all_done",
"total": total,
"total_updated": total_updated,
"total_failed": total_failed,
})
logger.info("refresh_all_meta: завершено, обновлено файлов: {}, ошибок: {}",
total_updated, total_failed)
finally:
_refresh_all_running = False
class UpdateMetaRequest(BaseModel):
url: str
title_ru: str

View File

@@ -13,7 +13,7 @@ from .browser import BrowserManager
from .sources import registry, get_source_for_url, extract_domain
import json as _json
from .sources.base import Chapter, MangaInfo, AuthRequiredError
from .exporter import export, MangaMeta
from .exporter import export, patch_meta, MangaMeta
from .state import StateDB
from .utils import safe_name, safe_chapter_name
@@ -555,3 +555,129 @@ async def check_for_updates(
finally:
db.close()
async def refresh_manga_metadata(
url: str,
output_dir: Path = OUTPUT_DIR,
on_event: Optional[Callable] = None,
) -> tuple[int, int]:
"""
Обновляет метаданные манги через браузер: скачивает обложку через Playwright,
обновляет ComicInfo.xml/PDF/EPUB с актуальными данными (включая жанры и синопсис).
Возвращает (updated, failed).
"""
async def emit(event: dict):
if on_event:
try:
await on_event(event)
except Exception:
pass
db = StateDB()
db_lock = asyncio.Lock()
async def db_call(fn, *args, **kwargs):
async with db_lock:
return fn(*args, **kwargs)
try:
source = get_source_for_url(url, db)
if source is None:
manga_row = await db_call(db.get_manga, url)
if manga_row and manga_row.get("source_id"):
source = registry.get_by_db_id(manga_row["source_id"], db)
if source is None:
logger.warning("refresh_manga_metadata: источник не найден для {}", url)
return 0, 0
# Inject auth token for sources that need it
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:
_, page = await bm.new_page()
try:
manga = await source.get_manga_info(page, url)
if not manga:
logger.warning("refresh_manga_metadata: get_manga_info вернул None для {}", url)
return 0, 0
# Сохраняем свежие данные в БД
await db_call(
db.update_manga_info, url,
title=manga.title_ru or manga.title,
chapters_total=len(manga.chapters),
title_ru=manga.title_ru,
title_full=manga.title_full,
pub_status=manga.pub_status,
description=manga.description,
tags=_json.dumps(manga.tags, ensure_ascii=False) if manga.tags else "",
cover_url=manga.cover_url,
)
# Скачиваем обложку через Playwright (правильные куки/заголовки)
manga_row = await db_call(db.get_manga, url)
manga_fmt = (manga_row or {}).get("format", "cbz") or "cbz"
folder_name = (
(manga_row.get("folder_name") if manga_row else None)
or safe_name(manga.title_ru or manga.title)
)
manga_dir = output_dir / folder_name
if manga.cover_url and manga_fmt in ("cbz", "all") and manga_dir.exists():
await _download_cover(manga.cover_url, manga_dir, url, page)
# Обновляем метаданные в файлах с актуальными данными из источника
chapters = await db_call(db.get_all_chapters, url)
chapters_total = len(chapters)
series = manga.title_ru or manga.title
series_full = manga.title_full or ""
pub_status = manga.pub_status or "unknown"
summary = manga.description or ""
tags_str = ", ".join(manga.tags) if manga.tags else ""
genre_str = ", ".join(manga.genres) if manga.genres else ""
def do_patch():
updated = failed = 0
for ch in chapters:
for fmt_col in ("output_cbz", "output_pdf", "output_epub"):
fpath = ch.get(fmt_col)
if not fpath:
continue
p = Path(fpath)
if not p.exists():
continue
meta = MangaMeta(
series=series,
series_full=series_full,
chapter_title=ch.get("title") or "",
number=float(ch.get("number") or 0),
volume=int(ch.get("volume") or 0),
chapters_total=chapters_total,
pub_status=pub_status,
source_url=url,
summary=summary,
tags=tags_str,
genre=genre_str,
)
if patch_meta(p, meta):
updated += 1
else:
failed += 1
return updated, failed
updated, failed = await asyncio.to_thread(do_patch)
logger.info("refresh_manga_metadata {}: обновлено {}, ошибок {}", url, updated, failed)
return updated, failed
finally:
await page.close()
finally:
db.close()