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