This commit is contained in:
2026-05-03 13:12:55 +03:00
parent 07bc7ef1e0
commit 2cb244d973
2 changed files with 112 additions and 51 deletions

View File

@@ -50,6 +50,8 @@
@keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} } @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; } .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} } @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; } ::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:#0f1117; } ::-webkit-scrollbar-thumb { background:#2d3148; border-radius:3px; }
/* Login screen */ /* Login screen */
#login-screen { position:fixed; inset:0; z-index:9999; background:#0f1117; display:flex; align-items:center; justify-content:center; } #login-screen { position:fixed; inset:0; z-index:9999; background:#0f1117; display:flex; align-items:center; justify-content:center; }
@@ -166,6 +168,12 @@
<!-- Manga List --> <!-- Manga List -->
<div id="tab-content-mangas"> <div id="tab-content-mangas">
<div class="px-4 py-2 border-b border-gray-800">
<input id="manga-search" type="search" placeholder="🔍 Поиск по названию..."
oninput="onMangaSearch(this.value)"
class="w-full px-3 py-1.5 text-sm rounded-lg"
style="background:#0f1117;border:1px solid #2d3148;color:#e2e8f0;outline:none">
</div>
<div id="manga-list" class="divide-y divide-gray-800"> <div id="manga-list" class="divide-y divide-gray-800">
<div class="px-5 py-8 text-center text-gray-500 text-sm">Загрузка...</div> <div class="px-5 py-8 text-center text-gray-500 text-sm">Загрузка...</div>
</div> </div>
@@ -380,9 +388,11 @@ const state = {
mangas: {}, // url → manga object mangas: {}, // url → manga object
chapters: {}, // manga_url → [chapter, ...] chapters: {}, // manga_url → [chapter, ...]
filter: 'all', filter: 'all',
search: '',
sources: [], // [{id, slug, display_name, domains}] sources: [], // [{id, slug, display_name, domains}]
currentUser: null, // {id, username, role} currentUser: null, // {id, username, role}
authWarnings: {}, // source_slug → {source_slug, source_name} authWarnings: {}, // source_slug → {source_slug, source_name}
metaUpdating: new Set(), // urls where meta refresh is in progress
}; };
// ── Auth ───────────────────────────────────── // ── Auth ─────────────────────────────────────
@@ -539,7 +549,7 @@ function handleEvent(msg) {
if(!state.mangas[msg.url]) { if(!state.mangas[msg.url]) {
const srcInfo = msg.source_id ? (state.sources.find(s => s.id === msg.source_id) || null) : null; 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, 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: msg.added_by || null,
added_by_username: msg.added_by_username || null, added_by_username: msg.added_by_username || null,
source: srcInfo ? {id: srcInfo.id, slug: srcInfo.slug, display_name: srcInfo.display_name} : null }; source: srcInfo ? {id: srcInfo.id, slug: srcInfo.slug, display_name: srcInfo.display_name} : null };
@@ -683,8 +693,14 @@ function handleEvent(msg) {
loadStats(); loadStats();
break; break;
case 'meta_refresh_started':
state.metaUpdating.add(msg.url);
_updateMetaBtn(msg.url);
break;
case 'meta_refreshed': case 'meta_refreshed':
// Ничего не делаем визуально — файлы обновлены на диске state.metaUpdating.delete(msg.url);
_updateMetaBtn(msg.url, msg.failed === -1 ? 'error' : 'done');
break; break;
case 'manga_meta_updated': case 'manga_meta_updated':
@@ -1623,34 +1639,56 @@ async function confirmDelete() {
loadStats(); loadStats();
} }
async function refreshMeta(url) { function _updateMetaBtn(url, result) {
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'}); const btn = document.getElementById('modal-refresh-meta-btn');
if(r.ok) { if(!btn) return;
const btn = document.querySelector(`[data-refresh-url="${CSS.escape(url)}"]`); const inProgress = state.metaUpdating.has(url);
if(btn) { btn.textContent = '✓'; btn.disabled = true; setTimeout(() => { btn.textContent = '🏷'; btn.disabled = false; }, 2000); } if(inProgress) {
btn.innerHTML = '<span class="meta-spinner"></span> Обновляем...';
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) { async function refreshMeta(url) {
const btn = document.getElementById('modal-refresh-meta-btn');
if(btn) { btn.textContent = '⏳ Обновляем...'; btn.disabled = true; }
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'}); const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
if(btn) { if(!r.ok) return;
if(r.ok) { // state будет обновлён через WS meta_refresh_started
btn.textContent = '✅ Метатеги обновлены'; }
btn.style.color = '#4ade80';
btn.style.borderColor = '#166534'; async function refreshMetaModal(url) {
setTimeout(() => { const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
btn.textContent = '🏷 Обновить метатеги'; if(!r.ok) {
btn.disabled = false; const btn = document.getElementById('modal-refresh-meta-btn');
btn.style.color = '#a78bfa'; if(btn) { btn.innerHTML = '❌ Ошибка'; }
btn.style.borderColor = '#312e81';
}, 2500);
} else {
btn.textContent = '❌ Ошибка';
btn.disabled = false;
}
} }
// Спиннер появится через WS meta_refresh_started, исчезнет через meta_refreshed
} }
async function forceRedownload(url, closeModalAfter = false) { async function forceRedownload(url, closeModalAfter = false) {
@@ -1943,6 +1981,12 @@ function _rowAuto(m) {
</div>`; </div>`;
} }
let _searchTimer = null;
function onMangaSearch(val) {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => { state.search = val.trim().toLowerCase(); renderList(); }, 120);
}
function _sortedMangas() { function _sortedMangas() {
let mangas = Object.values(state.mangas); let mangas = Object.values(state.mangas);
if(state.filter === 'ongoing') { if(state.filter === 'ongoing') {
@@ -1950,6 +1994,14 @@ function _sortedMangas() {
} else if(state.filter !== 'all') { } else if(state.filter !== 'all') {
mangas = mangas.filter(m => m.status === state.filter); 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}; const order = {downloading: 0, queued: 1, stopped: 2, failed: 3, done: 4};
mangas.sort((a, b) => { mangas.sort((a, b) => {
const oa = order[a.status] ?? 2, ob = order[b.status] ?? 2; const oa = order[a.status] ?? 2, ob = order[b.status] ?? 2;

View File

@@ -266,7 +266,8 @@ def _format_size(bytes_val: int) -> str:
bytes_val /= 1024 bytes_val /= 1024
return f"{bytes_val:.1f} ТБ" return f"{bytes_val:.1f} ТБ"
def _enrich_manga(m: dict, db: StateDB) -> dict: 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"]) stats = db.get_chapter_stats(m["url"])
source_info = None source_info = None
if m.get("source_id"): if m.get("source_id"):
@@ -678,6 +679,33 @@ 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"]
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): async def _do_refresh_meta(url: str):
db = StateDB() db = StateDB()
try: try:
@@ -687,36 +715,17 @@ async def _do_refresh_meta(url: str):
chapters = db.get_all_chapters(url) chapters = db.get_all_chapters(url)
chapters_total = len(chapters) chapters_total = len(chapters)
pub_status = manga.get("pub_status", "unknown") or "unknown" pub_status = manga.get("pub_status", "unknown") or "unknown"
updated = failed = 0 finally:
for ch in chapters: db.close()
for fmt_col, ext in (("output_cbz", ".cbz"), ("output_pdf", ".pdf"), ("output_epub", ".epub")): try:
fpath = ch.get(fmt_col) await ws_manager.broadcast({"type": "meta_refresh_started", "url": url})
if not fpath: updated, failed = await asyncio.to_thread(_patch_meta_sync, manga, chapters, chapters_total, pub_status)
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
logger.info("refresh_meta {}: обновлено {}, ошибок {}", url, updated, failed) logger.info("refresh_meta {}: обновлено {}, ошибок {}", url, updated, failed)
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)
finally: await ws_manager.broadcast({"type": "meta_refreshed", "url": url, "updated": 0, "failed": -1})
db.close()
class UpdateMetaRequest(BaseModel): class UpdateMetaRequest(BaseModel):
url: str url: str
title_ru: str title_ru: str