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>
<div id="users-list" class="flex flex-col gap-2"></div> <div id="users-list" class="flex flex-col gap-2"></div>
</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"> <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> <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'); _updateMetaBtn(msg.url, msg.failed === -1 ? 'error' : 'done');
break; 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': case 'manga_meta_updated':
if(state.mangas[msg.url]) { if(state.mangas[msg.url]) {
state.mangas[msg.url].title = msg.title; state.mangas[msg.url].title = msg.title;
@@ -812,7 +837,7 @@ function switchTab(tab) {
document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas'); document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas');
if(tab === 'history') loadHistory(); if(tab === 'history') loadHistory();
if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); } if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); }
if(tab === 'settings') { loadSources(); showUsersSection(); } if(tab === 'settings') { loadSources(); showUsersSection(); showRefreshAllSection(); }
} }
function updateNewsBadge() { 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() { async function loadUsers() {
if(!isAdmin()) return; if(!isAdmin()) return;
try { try {
@@ -1654,7 +1684,7 @@ function _updateMetaBtn(url, result) {
btn.style.color = '#4ade80'; btn.style.color = '#4ade80';
btn.style.borderColor = '#166534'; btn.style.borderColor = '#166534';
setTimeout(() => { setTimeout(() => {
btn.innerHTML = '🏷 Обновить метатеги'; btn.innerHTML = '🏷 Обновить метаданные';
btn.style.color = '#a78bfa'; btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81'; btn.style.borderColor = '#312e81';
}, 2500); }, 2500);
@@ -1664,12 +1694,12 @@ function _updateMetaBtn(url, result) {
btn.style.color = '#f87171'; btn.style.color = '#f87171';
btn.style.borderColor = '#7f1d1d'; btn.style.borderColor = '#7f1d1d';
setTimeout(() => { setTimeout(() => {
btn.innerHTML = '🏷 Обновить метатеги'; btn.innerHTML = '🏷 Обновить метаданные';
btn.style.color = '#a78bfa'; btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81'; btn.style.borderColor = '#312e81';
}, 3000); }, 3000);
} else { } else {
btn.innerHTML = '🏷 Обновить метатеги'; btn.innerHTML = '🏷 Обновить метаданные';
btn.disabled = false; btn.disabled = false;
btn.style.color = '#a78bfa'; btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81'; btn.style.borderColor = '#312e81';
@@ -1691,6 +1721,46 @@ async function refreshMetaModal(url) {
// Спиннер появится через WS meta_refresh_started, исчезнет через meta_refreshed // Спиннер появится через 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) { async function forceRedownload(url, closeModalAfter = false) {
if(!confirm('Скачать заново ВСЕ главы? Уже скачанные файлы будут перезаписаны.')) return; if(!confirm('Скачать заново ВСЕ главы? Уже скачанные файлы будут перезаписаны.')) return;
const r = await fetch('/api/mangas/force_redownload?url='+encodeURIComponent(url), {method:'POST'}); 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)}')" <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" 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"> style="background:#1e1b4b;color:#a78bfa;border:1px solid #312e81">
🏷 Обновить метатеги 🏷 Обновить метаданные
</button>` : ''} </button>` : ''}
${data.status !== 'downloading' && data.status !== 'queued' && isAdmin() ? ` ${data.status !== 'downloading' && data.status !== 'queued' && isAdmin() ? `
<button onclick="forceRedownloadModal('${escHtml(data.url)}')" <button onclick="forceRedownloadModal('${escHtml(data.url)}')"

View File

@@ -18,7 +18,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from loguru import logger from loguru import logger
from .state import StateDB 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 .browser import BrowserManager
from .exporter import patch_meta, MangaMeta from .exporter import patch_meta, MangaMeta
from .sources import registry, get_source_for_url, extract_domain from .sources import registry, get_source_for_url, extract_domain
@@ -73,6 +73,7 @@ ws_manager = ConnectionManager()
# ── Очередь загрузки ───────────────────────── # ── Очередь загрузки ─────────────────────────
download_queue: asyncio.Queue = asyncio.Queue() download_queue: asyncio.Queue = asyncio.Queue()
active_tasks: dict = {} active_tasks: dict = {}
_refresh_all_running: bool = False
async def _broadcast_queue_positions(): async def _broadcast_queue_positions():
queue_list = list(download_queue._queue) queue_list = list(download_queue._queue)
positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)} 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() db.close()
asyncio.create_task(_do_refresh_meta(url)) asyncio.create_task(_do_refresh_meta(url))
return {"ok": True} 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): 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: try:
await ws_manager.broadcast({"type": "meta_refresh_started", "url": url}) 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) updated, failed = await refresh_manga_metadata(url)
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)
await ws_manager.broadcast({"type": "meta_refreshed", "url": url, await ws_manager.broadcast({"type": "meta_refreshed", "url": url,
"updated": updated, "failed": failed}) "updated": updated, "failed": failed})
except Exception as e: except Exception as e:
logger.error("_do_refresh_meta {}: {}", url, e) logger.error("_do_refresh_meta {}: {}", url, e)
await ws_manager.broadcast({"type": "meta_refreshed", "url": url, "updated": 0, "failed": -1}) 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): class UpdateMetaRequest(BaseModel):
url: str url: str
title_ru: str title_ru: str

View File

@@ -13,7 +13,7 @@ from .browser import BrowserManager
from .sources import registry, get_source_for_url, extract_domain from .sources import registry, get_source_for_url, extract_domain
import json as _json import json as _json
from .sources.base import Chapter, MangaInfo, AuthRequiredError from .sources.base import Chapter, MangaInfo, AuthRequiredError
from .exporter import export, MangaMeta from .exporter import export, patch_meta, MangaMeta
from .state import StateDB from .state import StateDB
from .utils import safe_name, safe_chapter_name from .utils import safe_name, safe_chapter_name
@@ -555,3 +555,129 @@ async def check_for_updates(
finally: finally:
db.close() 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()