upd
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
61
src/api.py
61
src/api.py
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user