diff --git a/frontend/index.html b/frontend/index.html index a8e479b..adea8db 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -50,6 +50,8 @@ @keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} } .pulse-dot { width:8px; height:8px; border-radius:50%; background:#3b82f6; animation:pulse 1.5s infinite; } @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} } + .meta-spinner { display:inline-block; width:12px; height:12px; border:2px solid #4f46e5; border-top-color:transparent; border-radius:50%; animation:spin 0.7s linear infinite; vertical-align:middle; } + @keyframes spin { to { transform:rotate(360deg); } } ::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:#0f1117; } ::-webkit-scrollbar-thumb { background:#2d3148; border-radius:3px; } /* Login screen */ #login-screen { position:fixed; inset:0; z-index:9999; background:#0f1117; display:flex; align-items:center; justify-content:center; } @@ -166,6 +168,12 @@
+
+ +
Загрузка...
@@ -380,9 +388,11 @@ const state = { mangas: {}, // url → manga object chapters: {}, // manga_url → [chapter, ...] filter: 'all', + search: '', sources: [], // [{id, slug, display_name, domains}] currentUser: null, // {id, username, role} authWarnings: {}, // source_slug → {source_slug, source_name} + metaUpdating: new Set(), // urls where meta refresh is in progress }; // ── Auth ───────────────────────────────────── @@ -539,7 +549,7 @@ function handleEvent(msg) { if(!state.mangas[msg.url]) { const srcInfo = msg.source_id ? (state.sources.find(s => s.id === msg.source_id) || null) : null; state.mangas[msg.url] = { url: msg.url, title: msg.url, status: 'queued', format: msg.format, - chapters_total: 0, chapters_done: 0, size_human: '—', + chapters_total: 0, chapters_done: 0, size_human: '0.0 Б', added_by: msg.added_by || null, added_by_username: msg.added_by_username || null, source: srcInfo ? {id: srcInfo.id, slug: srcInfo.slug, display_name: srcInfo.display_name} : null }; @@ -683,8 +693,14 @@ function handleEvent(msg) { loadStats(); break; + case 'meta_refresh_started': + state.metaUpdating.add(msg.url); + _updateMetaBtn(msg.url); + break; + case 'meta_refreshed': - // Ничего не делаем визуально — файлы обновлены на диске + state.metaUpdating.delete(msg.url); + _updateMetaBtn(msg.url, msg.failed === -1 ? 'error' : 'done'); break; case 'manga_meta_updated': @@ -1623,34 +1639,56 @@ async function confirmDelete() { loadStats(); } -async function refreshMeta(url) { - const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'}); - if(r.ok) { - const btn = document.querySelector(`[data-refresh-url="${CSS.escape(url)}"]`); - if(btn) { btn.textContent = '✓'; btn.disabled = true; setTimeout(() => { btn.textContent = '🏷'; btn.disabled = false; }, 2000); } +function _updateMetaBtn(url, result) { + const btn = document.getElementById('modal-refresh-meta-btn'); + if(!btn) return; + const inProgress = state.metaUpdating.has(url); + if(inProgress) { + btn.innerHTML = ' Обновляем...'; + btn.disabled = true; + btn.style.color = '#94a3b8'; + btn.style.borderColor = '#334155'; + } else if(result === 'done') { + btn.innerHTML = '✅ Готово'; + btn.disabled = false; + btn.style.color = '#4ade80'; + btn.style.borderColor = '#166534'; + setTimeout(() => { + btn.innerHTML = '🏷 Обновить метатеги'; + btn.style.color = '#a78bfa'; + btn.style.borderColor = '#312e81'; + }, 2500); + } else if(result === 'error') { + btn.innerHTML = '❌ Ошибка'; + btn.disabled = false; + btn.style.color = '#f87171'; + btn.style.borderColor = '#7f1d1d'; + setTimeout(() => { + btn.innerHTML = '🏷 Обновить метатеги'; + btn.style.color = '#a78bfa'; + btn.style.borderColor = '#312e81'; + }, 3000); + } else { + btn.innerHTML = '🏷 Обновить метатеги'; + btn.disabled = false; + btn.style.color = '#a78bfa'; + btn.style.borderColor = '#312e81'; } } -async function refreshMetaModal(url) { - const btn = document.getElementById('modal-refresh-meta-btn'); - if(btn) { btn.textContent = '⏳ Обновляем...'; btn.disabled = true; } +async function refreshMeta(url) { const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'}); - if(btn) { - if(r.ok) { - btn.textContent = '✅ Метатеги обновлены'; - btn.style.color = '#4ade80'; - btn.style.borderColor = '#166534'; - setTimeout(() => { - btn.textContent = '🏷 Обновить метатеги'; - btn.disabled = false; - btn.style.color = '#a78bfa'; - btn.style.borderColor = '#312e81'; - }, 2500); - } else { - btn.textContent = '❌ Ошибка'; - btn.disabled = false; - } + if(!r.ok) return; + // state будет обновлён через WS meta_refresh_started +} + +async function refreshMetaModal(url) { + const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'}); + if(!r.ok) { + const btn = document.getElementById('modal-refresh-meta-btn'); + if(btn) { btn.innerHTML = '❌ Ошибка'; } } + // Спиннер появится через WS meta_refresh_started, исчезнет через meta_refreshed } async function forceRedownload(url, closeModalAfter = false) { @@ -1943,6 +1981,12 @@ function _rowAuto(m) {
`; } +let _searchTimer = null; +function onMangaSearch(val) { + clearTimeout(_searchTimer); + _searchTimer = setTimeout(() => { state.search = val.trim().toLowerCase(); renderList(); }, 120); +} + function _sortedMangas() { let mangas = Object.values(state.mangas); if(state.filter === 'ongoing') { @@ -1950,6 +1994,14 @@ function _sortedMangas() { } else if(state.filter !== 'all') { mangas = mangas.filter(m => m.status === state.filter); } + if(state.search) { + const q = state.search; + mangas = mangas.filter(m => + (m.title || '').toLowerCase().includes(q) || + (m.title_ru || '').toLowerCase().includes(q) || + (m.title_full || '').toLowerCase().includes(q) + ); + } const order = {downloading: 0, queued: 1, stopped: 2, failed: 3, done: 4}; mangas.sort((a, b) => { const oa = order[a.status] ?? 2, ob = order[b.status] ?? 2; diff --git a/src/api.py b/src/api.py index 793c294..a3be558 100644 --- a/src/api.py +++ b/src/api.py @@ -266,7 +266,8 @@ def _format_size(bytes_val: int) -> str: bytes_val /= 1024 return f"{bytes_val:.1f} ТБ" def _enrich_manga(m: dict, db: StateDB) -> dict: - size_bytes = _dir_size(_manga_folder(m)) + folder = _manga_folder(m) + size_bytes = _dir_size(folder) if (m.get("folder_name") or m.get("title")) else 0 stats = db.get_chapter_stats(m["url"]) source_info = None if m.get("source_id"): @@ -678,6 +679,33 @@ 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"] + 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, + ) + if patch_meta(p, meta): + updated += 1 + else: + failed += 1 + return updated, failed + async def _do_refresh_meta(url: str): db = StateDB() try: @@ -687,36 +715,17 @@ async def _do_refresh_meta(url: str): chapters = db.get_all_chapters(url) chapters_total = len(chapters) pub_status = manga.get("pub_status", "unknown") or "unknown" - updated = failed = 0 - for ch in chapters: - for fmt_col, ext in (("output_cbz", ".cbz"), ("output_pdf", ".pdf"), ("output_epub", ".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, - ) - if patch_meta(p, meta): - updated += 1 - else: - failed += 1 + 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) await ws_manager.broadcast({"type": "meta_refreshed", "url": url, "updated": updated, "failed": failed}) except Exception as e: logger.error("_do_refresh_meta {}: {}", url, e) - finally: - db.close() + await ws_manager.broadcast({"type": "meta_refreshed", "url": url, "updated": 0, "failed": -1}) class UpdateMetaRequest(BaseModel): url: str title_ru: str