Add MangaLib source

This commit is contained in:
2026-05-02 20:03:21 +03:00
parent bc7b5bfe37
commit ebc1825794
11 changed files with 1508 additions and 491 deletions

View File

@@ -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; }
@@ -106,6 +108,9 @@
<!-- Stats Row -->
<div id="stats-row" class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6"></div>
<!-- Auth Warnings -->
<div id="auth-warnings" class="hidden mb-4 flex flex-col gap-2"></div>
<!-- Add Manga Panel -->
<div class="card rounded-xl p-5 mb-6">
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Добавить мангу</h2>
@@ -157,11 +162,18 @@
<button onclick="filterMangas('done')" id="filter-done" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Готово</button>
<button onclick="filterMangas('failed')" id="filter-failed" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Ошибки</button>
<button onclick="filterMangas('stopped')" id="filter-stopped" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Остановлены</button>
<button onclick="filterMangas('ongoing')" id="filter-ongoing" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">🔄 Продолжаются</button>
</div>
</div>
<!-- Manga List -->
<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 class="px-5 py-8 text-center text-gray-500 text-sm">Загрузка...</div>
</div>
@@ -376,8 +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 ─────────────────────────────────────
@@ -523,6 +538,7 @@ function handleEvent(msg) {
case 'snapshot':
msg.mangas.forEach(m => { state.mangas[m.url] = m; });
renderList();
renderAuthWarnings();
loadStats();
// Дополнительно запрашиваем свежие данные с сервера — на случай если
// пока WS был отключён, статусы изменились и события были потеряны
@@ -533,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 };
@@ -677,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':
@@ -750,6 +772,29 @@ function handleEvent(msg) {
updateMangaRow(msg.url);
}
break;
case 'auth_required':
if(state.mangas[msg.url]) {
state.mangas[msg.url].status = 'stopped';
state.mangas[msg.url].last_error = `auth_required:${msg.source_slug}`;
if(msg.finished_at) state.mangas[msg.url].finished_at = msg.finished_at;
}
state.authWarnings[msg.source_slug] = {source_slug: msg.source_slug, source_name: msg.source_slug};
renderList();
renderAuthWarnings();
loadStats();
break;
case 'source_settings_updated':
loadSources().then(() => {
// Clear warnings for sources that now have a token
state.sources.forEach(s => {
if(s.has_token) delete state.authWarnings[s.slug];
});
// Refresh mangas to get cleared last_error values
_refreshMangaList().then(() => renderAuthWarnings());
});
break;
}
}
@@ -1300,6 +1345,21 @@ function renderSources() {
</button>
</span>` : ''}
</div>
${s.supports_auth_token && isAdmin() ? `
<div class="mt-3 pt-3" style="border-top:1px solid #1e293b">
<div class="text-xs text-gray-400 mb-2">Токен авторизации (Bearer JWT)</div>
${s.has_token ? `<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-green-400">✓ Токен сохранён</span>
<button onclick="clearSourceToken(${s.id})" class="text-xs px-2 py-1 rounded" style="background:#1e293b;color:#ef4444;border:1px solid #374151">Удалить</button>
</div>` : ''}
<div class="flex items-center gap-2">
<input id="token-input-${s.id}" type="password" placeholder="${s.has_token ? 'Введите новый токен для замены' : 'eyJ0eXAiOiJKV1Qi...'}"
class="text-xs px-2 py-1 rounded flex-1" style="background:#0f1117;border:1px solid #334155;color:#e2e8f0;min-width:0"
onkeydown="if(event.key==='Enter') saveSourceToken(${s.id})">
<button onclick="saveSourceToken(${s.id})" class="text-xs px-3 py-1 rounded font-semibold flex-shrink-0" style="background:#4f46e5;color:white">Сохранить</button>
</div>
</div>
` : ''}
</div>
`).join('');
}
@@ -1358,6 +1418,78 @@ async function removeDomain(sourceId, domain) {
}
}
async function saveSourceToken(sourceId) {
const input = document.getElementById('token-input-' + sourceId);
if(!input) return;
const token = input.value.trim();
try {
const r = await fetch(`/api/sources/${sourceId}/settings`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({settings: {auth_token: token}}),
});
if(!r.ok) {
const err = await r.json();
_showNotification('Ошибка: ' + (err.detail || 'неизвестная ошибка'), 'error');
return;
}
input.value = '';
_showNotification('Токен сохранён', 'success');
await loadSources();
} catch(e) {
_showNotification('Ошибка: ' + e.message, 'error');
}
}
async function clearSourceToken(sourceId) {
if(!confirm('Удалить токен авторизации?')) return;
try {
const r = await fetch(`/api/sources/${sourceId}/settings`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({settings: {auth_token: ''}}),
});
if(r.ok) {
_showNotification('Токен удалён', 'success');
await loadSources();
}
} catch(e) {}
}
function renderAuthWarnings() {
const container = document.getElementById('auth-warnings');
if(!container) return;
// Collect unique source slugs with unresolved auth errors from current manga state
const slugs = {};
Object.values(state.mangas).forEach(m => {
const err = m.last_error || '';
if(err.startsWith('auth_required:')) {
const slug = err.slice('auth_required:'.length);
if(!slugs[slug]) {
const src = state.sources.find(s => s.slug === slug);
slugs[slug] = src ? src.display_name : slug;
}
}
});
// Also include warnings from state.authWarnings (received via WS before manga list refresh)
Object.entries(state.authWarnings).forEach(([slug, info]) => {
if(!slugs[slug]) slugs[slug] = info.source_name || slug;
});
const entries = Object.entries(slugs);
if(!entries.length) {
container.classList.add('hidden');
container.innerHTML = '';
return;
}
container.classList.remove('hidden');
container.innerHTML = entries.map(([slug, name]) => `
<div class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm" style="background:#431407;border:1px solid #7c2d12;color:#fed7aa">
<span style="font-size:1.1rem">⚠</span>
<span>Токен авторизации для <strong>${escHtml(name)}</strong> устарел или отсутствует. Обновите токен в <button onclick="switchTab('settings')" class="underline hover:text-orange-200">Настройках</button>.</span>
</div>
`).join('');
}
// ── Switch Source Modal ───────────────────────
let _switchSourceUrl = null;
@@ -1507,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 = '<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) {
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) {
@@ -1827,9 +1981,27 @@ function _rowAuto(m) {
</div>`;
}
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 !== 'all') mangas = mangas.filter(m => m.status === state.filter);
if(state.filter === 'ongoing') {
mangas = mangas.filter(m => m.pub_status === 'ongoing');
} 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;
@@ -2310,6 +2482,7 @@ async function _refreshMangaList() {
const mangas = await r.json();
mangas.forEach(m => { state.mangas[m.url] = m; });
renderList();
renderAuthWarnings();
} catch(e) {}
}