This commit is contained in:
2026-04-30 17:14:21 +03:00
parent 0aa057c991
commit 7c5ce807b8
4 changed files with 345 additions and 11 deletions

View File

@@ -192,6 +192,58 @@
</div> </div>
</div> </div>
<!-- Модалка редактирования метаданных -->
<div id="edit-meta-modal" class="fixed inset-0 z-[60] hidden items-center justify-center" style="background:rgba(0,0,0,0.75)">
<div class="card rounded-2xl w-full max-w-md mx-4 p-6 flex flex-col gap-4">
<h3 class="font-semibold text-white text-base">✏️ Редактировать название</h3>
<div class="flex flex-col gap-3">
<div>
<label class="text-xs text-gray-400 mb-1 block">Название (ru)</label>
<input id="edit-meta-title-ru" type="text"
class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
style="background:#0f1117" placeholder="Название на русском">
</div>
<div>
<label class="text-xs text-gray-400 mb-1 block">Полное название</label>
<input id="edit-meta-title-full" type="text"
class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
style="background:#0f1117" placeholder="Полное название">
</div>
<p class="text-xs text-gray-500">⚠️ Папка не переименуется. Метаданные файлов обновятся автоматически.</p>
</div>
<div class="flex gap-3 justify-end mt-2">
<button onclick="closeEditMeta()"
class="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white"
style="background:#1e293b">Отмена</button>
<button onclick="saveEditMeta()" id="edit-meta-save-btn"
class="px-4 py-2 rounded-lg text-sm font-semibold text-white"
style="background:#4f46e5">Сохранить</button>
</div>
</div>
</div>
<!-- Модалка переименования папки -->
<div id="rename-folder-modal" class="fixed inset-0 z-[60] hidden items-center justify-center" style="background:rgba(0,0,0,0.75)">
<div class="card rounded-2xl w-full max-w-md mx-4 p-6 flex flex-col gap-4">
<h3 class="font-semibold text-white text-base">📁 Переименовать папку</h3>
<div>
<label class="text-xs text-gray-400 mb-1 block">Новое имя папки</label>
<input id="rename-folder-input" type="text"
class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
style="background:#0f1117" placeholder="Название папки">
<p class="text-xs text-gray-500 mt-2">Специальные символы будут удалены. Пробелы заменятся на «_».</p>
</div>
<div class="flex gap-3 justify-end mt-2">
<button onclick="closeRenameFolder()"
class="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white"
style="background:#1e293b">Отмена</button>
<button onclick="saveRenameFolder()" id="rename-folder-save-btn"
class="px-4 py-2 rounded-lg text-sm font-semibold text-white"
style="background:#4f46e5">Переименовать</button>
</div>
</div>
</div>
<script> <script>
// ── State ──────────────────────────────────── // ── State ────────────────────────────────────
const state = { const state = {
@@ -377,6 +429,34 @@ function handleEvent(msg) {
// Ничего не делаем визуально — файлы обновлены на диске // Ничего не делаем визуально — файлы обновлены на диске
break; break;
case 'manga_meta_updated':
if(state.mangas[msg.url]) {
state.mangas[msg.url].title = msg.title;
state.mangas[msg.url].title_ru = msg.title_ru;
state.mangas[msg.url].title_full = msg.title_full;
updateMangaRow(msg.url);
}
break;
case 'manga_folder_renamed':
if(state.mangas[msg.url]) {
state.mangas[msg.url].folder_name = msg.folder_name;
}
break;
case 'queue_positions': {
// Обновляем queue_position для всех манг
const pos = msg.positions || {};
Object.values(state.mangas).forEach(m => {
const newPos = pos[m.url] ?? null;
if(m.queue_position !== newPos) {
m.queue_position = newPos;
updateMangaRow(m.url);
}
});
break;
}
case 'manga_done': case 'manga_done':
if(state.mangas[msg.url]) { if(state.mangas[msg.url]) {
state.mangas[msg.url].status = 'done'; state.mangas[msg.url].status = 'done';
@@ -948,7 +1028,7 @@ function _rowButtons(m) {
${m.status === 'stopped' || m.status === 'failed' ${m.status === 'stopped' || m.status === 'failed'
? `<button onclick="resumeManga('${u}')" title="Возобновить" style="background:#14532d;color:#86efac;padding:4px 12px;border-radius:6px;font-size:0.75rem;cursor:pointer">▶</button>` ? `<button onclick="resumeManga('${u}')" title="Возобновить" style="background:#14532d;color:#86efac;padding:4px 12px;border-radius:6px;font-size:0.75rem;cursor:pointer">▶</button>`
: ''} : ''}
${!isActive ${m.status === 'queued'
? `<button onclick="prioritizeManga('${u}')" title="Загрузить первой" style="background:#312e81;color:#a5b4fc;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">🚀</button>` ? `<button onclick="prioritizeManga('${u}')" title="Загрузить первой" style="background:#312e81;color:#a5b4fc;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">🚀</button>`
: ''} : ''}
<button onclick="deleteManga('${u}')" class="btn-danger" title="Удалить">✕</button> <button onclick="deleteManga('${u}')" class="btn-danger" title="Удалить">✕</button>
@@ -1185,6 +1265,17 @@ function renderModalBody(data) {
</details>` : '<div class="text-xs text-gray-500 mb-3">Файлов на диске нет</div>'} </details>` : '<div class="text-xs text-gray-500 mb-3">Файлов на диске нет</div>'}
<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-800"> <div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-800">
<button onclick="openEditMeta('${escHtml(data.url)}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
style="background:#1e293b;color:#94a3b8;border:1px solid #334155">
✏️ Редактировать название
</button>
${data.status !== 'downloading' ? `
<button onclick="openRenameFolder('${escHtml(data.url)}', '${escHtml(data.folder_name || '')}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
style="background:#1e293b;color:#94a3b8;border:1px solid #334155">
📁 Переименовать папку
</button>` : ''}
${data.status === 'done' ? ` ${data.status === 'done' ? `
<button id="modal-refresh-meta-btn" onclick="refreshMetaModal('${escHtml(data.url)}')" <button id="modal-refresh-meta-btn" onclick="refreshMetaModal('${escHtml(data.url)}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors" class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
@@ -1323,6 +1414,98 @@ function escHtml(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
// ── Edit Meta ────────────────────────────────
let _editMetaUrl = null;
function openEditMeta(url) {
_editMetaUrl = url;
const m = state.mangas[url] || {};
document.getElementById('edit-meta-title-ru').value = m.title_ru || m.title || '';
document.getElementById('edit-meta-title-full').value = m.title_full || '';
const modal = document.getElementById('edit-meta-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeEditMeta() {
const modal = document.getElementById('edit-meta-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
_editMetaUrl = null;
}
async function saveEditMeta() {
if(!_editMetaUrl) return;
const btn = document.getElementById('edit-meta-save-btn');
btn.disabled = true; btn.textContent = '⏳ Сохраняем...';
const title_ru = document.getElementById('edit-meta-title-ru').value.trim();
const title_full = document.getElementById('edit-meta-title-full').value.trim();
try {
const r = await fetch('/api/mangas/update_meta', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({url: _editMetaUrl, title_ru, title_full}),
});
if(!r.ok) throw new Error(await r.text());
// Update local state
if(state.mangas[_editMetaUrl]) {
state.mangas[_editMetaUrl].title = title_ru;
state.mangas[_editMetaUrl].title_ru = title_ru;
state.mangas[_editMetaUrl].title_full = title_full;
}
renderList();
closeEditMeta();
} catch(e) {
alert('Ошибка: ' + e.message);
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
// ── Rename Folder ────────────────────────────
let _renameFolderUrl = null;
function openRenameFolder(url, currentFolder) {
_renameFolderUrl = url;
const m = state.mangas[url] || {};
const cur = currentFolder || m.folder_name || (m.title_ru || m.title || '').replace(/[^\w\s\-]/g,'').trim().replace(/ /g,'_');
document.getElementById('rename-folder-input').value = cur;
const modal = document.getElementById('rename-folder-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeRenameFolder() {
const modal = document.getElementById('rename-folder-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
_renameFolderUrl = null;
}
async function saveRenameFolder() {
if(!_renameFolderUrl) return;
const btn = document.getElementById('rename-folder-save-btn');
btn.disabled = true; btn.textContent = '⏳ Переименовываем...';
const folder_name = document.getElementById('rename-folder-input').value.trim();
try {
const r = await fetch('/api/mangas/rename_folder', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({url: _renameFolderUrl, folder_name}),
});
if(!r.ok) throw new Error((await r.json()).detail || await r.text());
const data = await r.json();
if(state.mangas[_renameFolderUrl]) {
state.mangas[_renameFolderUrl].folder_name = data.folder_name;
}
closeRenameFolder();
} catch(e) {
alert('Ошибка: ' + e.message);
} finally {
btn.disabled = false; btn.textContent = 'Переименовать';
}
}
// ── Init ───────────────────────────────────── // ── Init ─────────────────────────────────────
async function init() { async function init() {
_initDeleteModal(); _initDeleteModal();
@@ -1344,6 +1527,12 @@ document.addEventListener('DOMContentLoaded', init);
document.getElementById('modal').addEventListener('click', function(e) { document.getElementById('modal').addEventListener('click', function(e) {
if(e.target === this) closeModal(); if(e.target === this) closeModal();
}); });
document.getElementById('edit-meta-modal').addEventListener('click', function(e) {
if(e.target === this) closeEditMeta();
});
document.getElementById('rename-folder-modal').addEventListener('click', function(e) {
if(e.target === this) closeRenameFolder();
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -55,6 +55,13 @@ download_queue: asyncio.Queue = asyncio.Queue()
active_tasks: dict[str, asyncio.Task] = {} active_tasks: dict[str, asyncio.Task] = {}
async def _broadcast_queue_positions():
"""Отправляет всем клиентам актуальные позиции в очереди."""
queue_list = list(download_queue._queue) # type: ignore
positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)}
await ws_manager.broadcast({"type": "queue_positions", "positions": positions})
async def queue_worker(): async def queue_worker():
"""Последовательно обрабатывает очередь загрузок. Перезапускается при краше.""" """Последовательно обрабатывает очередь загрузок. Перезапускается при краше."""
while True: while True:
@@ -84,9 +91,12 @@ async def _queue_worker_loop():
if skip: if skip:
download_queue.task_done() download_queue.task_done()
await _broadcast_queue_positions()
continue continue
logger.info("Воркер: начинаю скачивать {}", url) logger.info("Воркер: начинаю скачивать {}", url)
# Позиции изменились — уведомляем клиентов
await _broadcast_queue_positions()
dl_task = asyncio.create_task(download_manga( dl_task = asyncio.create_task(download_manga(
url=url, url=url,
fmt=fmt, fmt=fmt,
@@ -116,6 +126,7 @@ async def _queue_worker_loop():
finally: finally:
active_tasks.pop(url, None) active_tasks.pop(url, None)
download_queue.task_done() download_queue.task_done()
await _broadcast_queue_positions()
@app.on_event("startup") @app.on_event("startup")
@@ -186,6 +197,19 @@ async def _run_auto_updates():
# ── Вспомогательные функции ─────────────────── # ── Вспомогательные функции ───────────────────
def _safe_name(s: str) -> str:
return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80]
def _manga_folder(m: dict) -> Path:
"""Возвращает папку манги с учётом кастомного имени."""
if m.get("folder_name"):
return OUTPUT_DIR / m["folder_name"]
title = m.get("title") or ""
safe_title = _safe_name(title)
return OUTPUT_DIR / safe_title
def _dir_size(path: Path) -> int: def _dir_size(path: Path) -> int:
"""Размер директории в байтах.""" """Размер директории в байтах."""
if not path.exists(): if not path.exists():
@@ -203,9 +227,7 @@ def _format_size(bytes_val: int) -> str:
def _enrich_manga(m: dict, db: StateDB) -> dict: def _enrich_manga(m: dict, db: StateDB) -> dict:
"""Обогащает строку манги реальными счётчиками из таблицы chapters.""" """Обогащает строку манги реальными счётчиками из таблицы chapters."""
title = m.get("title") or "" size_bytes = _dir_size(_manga_folder(m))
safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80]
size_bytes = _dir_size(OUTPUT_DIR / safe_title)
ch_done_count = db.conn.execute( ch_done_count = db.conn.execute(
"SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'", "SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'",
(m["url"],) (m["url"],)
@@ -238,9 +260,7 @@ def _manga_detail(manga: dict, db: StateDB) -> dict:
chapters = db.get_all_chapters(url) chapters = db.get_all_chapters(url)
# Определяем директорию манги # Определяем директорию манги
title = manga.get("title") or "" manga_dir = _manga_folder(manga)
safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80]
manga_dir = OUTPUT_DIR / safe_title
size_bytes = _dir_size(manga_dir) size_bytes = _dir_size(manga_dir)
# Файлы # Файлы
@@ -361,6 +381,7 @@ async def add_to_queue(body: AddMangaRequest):
"url": url, "url": url,
"format": body.format, "format": body.format,
}) })
await _broadcast_queue_positions()
# Запускаем фоновую задачу предпросмотра (без Chromium — быстро) # Запускаем фоновую задачу предпросмотра (без Chromium — быстро)
asyncio.create_task(_fetch_preview(url)) asyncio.create_task(_fetch_preview(url))
else: else:
@@ -525,6 +546,7 @@ async def prioritize_manga(url: str):
"url": url, "url": url,
"preempted_url": current_url, "preempted_url": current_url,
}) })
await _broadcast_queue_positions()
return {"ok": True} return {"ok": True}
finally: finally:
db.close() db.close()
@@ -619,6 +641,96 @@ async def _do_refresh_meta(url: str):
db.close() db.close()
class UpdateMetaRequest(BaseModel):
url: str
title_ru: str
title_full: str = ""
@app.post("/api/mangas/update_meta")
async def update_meta(body: UpdateMetaRequest):
"""Обновить метаданные манги (название серии) и применить к файлам."""
db = StateDB()
try:
manga = db.get_manga(body.url)
if not manga:
raise HTTPException(status_code=404, detail="Манга не найдена")
db.update_manga_meta_fields(
body.url,
title_ru=body.title_ru or None,
title_full=body.title_full or None,
)
finally:
db.close()
# Обновляем метаданные в файлах фоново
asyncio.create_task(_do_refresh_meta(body.url))
await ws_manager.broadcast({
"type": "manga_meta_updated",
"url": body.url,
"title": body.title_ru,
"title_ru": body.title_ru,
"title_full": body.title_full,
})
return {"ok": True}
class RenameFolderRequest(BaseModel):
url: str
folder_name: str
@app.post("/api/mangas/rename_folder")
async def rename_folder(body: RenameFolderRequest):
"""Переименовать папку манги и обновить пути в БД."""
new_folder = _safe_name(body.folder_name)
if not new_folder:
raise HTTPException(status_code=400, detail="Некорректное имя папки")
db = StateDB()
try:
manga = db.get_manga(body.url)
if not manga:
raise HTTPException(status_code=404, detail="Манга не найдена")
if manga["status"] == "downloading" and body.url in active_tasks:
raise HTTPException(status_code=400, detail="Нельзя переименовать — манга загружается")
old_dir = _manga_folder(manga)
new_dir = OUTPUT_DIR / new_folder
if old_dir != new_dir:
if new_dir.exists():
raise HTTPException(status_code=400, detail=f"Папка '{new_folder}' уже существует")
if old_dir.exists():
import shutil
shutil.move(str(old_dir), str(new_dir))
logger.info("Папка переименована: {}{}", old_dir, new_dir)
# Обновляем пути в таблице chapters
chapters = db.get_all_chapters(body.url)
for ch in chapters:
updates = {}
for col in ("output_cbz", "output_pdf", "output_epub"):
p = ch.get(col)
if p and str(old_dir) in p:
updates[col] = p.replace(str(old_dir), str(new_dir))
if updates:
sets = ", ".join(f"{k}=?" for k in updates)
db.conn.execute(
f"UPDATE chapters SET {sets} WHERE chapter_url=?",
[*updates.values(), ch["chapter_url"]]
)
db.conn.commit()
db.set_folder_name(body.url, new_folder)
await ws_manager.broadcast({
"type": "manga_folder_renamed",
"url": body.url,
"folder_name": new_folder,
})
return {"ok": True, "folder_name": new_folder}
finally:
db.close()
@app.post("/api/mangas/force_redownload") @app.post("/api/mangas/force_redownload")
async def force_redownload(url: str): async def force_redownload(url: str):
"""Сбросить все главы на pending и поставить мангу заново в очередь.""" """Сбросить все главы на pending и поставить мангу заново в очередь."""
@@ -641,6 +753,7 @@ async def force_redownload(url: str):
db.update_manga_status(url, "queued") db.update_manga_status(url, "queued")
await download_queue.put({"url": url, "fmt": manga["format"], "resume": False}) await download_queue.put({"url": url, "fmt": manga["format"], "resume": False})
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]}) await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]})
await _broadcast_queue_positions()
return {"ok": True} return {"ok": True}
finally: finally:
db.close() db.close()
@@ -664,6 +777,7 @@ async def stop_manga(url: str):
# Манга в очереди (ещё не начата) — просто помечаем как stopped # Манга в очереди (ещё не начата) — просто помечаем как stopped
db.update_manga_status(url, "stopped") db.update_manga_status(url, "stopped")
await ws_manager.broadcast({"type": "manga_stopped", "url": url}) await ws_manager.broadcast({"type": "manga_stopped", "url": url})
await _broadcast_queue_positions()
return {"ok": True} return {"ok": True}
finally: finally:
@@ -684,6 +798,7 @@ async def resume_manga(url: str):
db.update_manga_status(url, "queued") db.update_manga_status(url, "queued")
await download_queue.put({"url": url, "fmt": manga["format"]}) await download_queue.put({"url": url, "fmt": manga["format"]})
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]}) await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]})
await _broadcast_queue_positions()
return {"ok": True} return {"ok": True}
finally: finally:
db.close() db.close()
@@ -701,9 +816,7 @@ async def delete_manga(url: str, delete_files: bool = False):
deleted_size = 0 deleted_size = 0
if delete_files: if delete_files:
title = manga.get("title") or "" manga_dir = _manga_folder(manga)
safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80]
manga_dir = OUTPUT_DIR / safe_title
if manga_dir.exists() and manga_dir.is_dir(): if manga_dir.exists() and manga_dir.is_dir():
deleted_size = _dir_size(manga_dir) deleted_size = _dir_size(manga_dir)
import shutil import shutil

View File

@@ -79,6 +79,7 @@ class StateDB:
("mangas", "last_checked_at", "TEXT"), ("mangas", "last_checked_at", "TEXT"),
("mangas", "started_at", "TEXT"), ("mangas", "started_at", "TEXT"),
("mangas", "finished_at", "TEXT"), ("mangas", "finished_at", "TEXT"),
("mangas", "folder_name", "TEXT"),
] ]
for table, col, typedef in migrations: for table, col, typedef in migrations:
try: try:
@@ -110,6 +111,32 @@ class StateDB:
""", (title, title_ru, title_full, pub_status, chapters_total, _now(), url)) """, (title, title_ru, title_full, pub_status, chapters_total, _now(), url))
self.conn.commit() self.conn.commit()
def set_folder_name(self, url: str, folder_name: str):
"""Установить кастомное имя папки для манги."""
self.conn.execute("""
UPDATE mangas SET folder_name=?, updated_at=? WHERE url=?
""", (folder_name, _now(), url))
self.conn.commit()
def update_manga_meta_fields(self, url: str, title_ru: str = None,
title_full: str = None):
"""Обновить пользовательские метаданные манги (название серии)."""
fields = []
params = []
if title_ru is not None:
fields.extend(["title_ru=?", "title=?"])
params.extend([title_ru, title_ru])
if title_full is not None:
fields.append("title_full=?")
params.append(title_full)
if not fields:
return
fields.append("updated_at=?")
params.append(_now())
params.append(url)
self.conn.execute(f"UPDATE mangas SET {', '.join(fields)} WHERE url=?", params)
self.conn.commit()
def set_auto_update(self, url: str, enabled: bool): def set_auto_update(self, url: str, enabled: bool):
self.conn.execute(""" self.conn.execute("""
UPDATE mangas SET auto_update=?, updated_at=? WHERE url=? UPDATE mangas SET auto_update=?, updated_at=? WHERE url=?

View File

@@ -92,7 +92,12 @@ async def download_manga(
"chapters_total": len(manga.chapters), "chapters_total": len(manga.chapters),
}) })
folder_name = _safe_name(manga.title_ru or manga.title) # Используем кастомное имя папки из БД, если задано
_db_manga = await db_call(db.get_manga, url)
folder_name = (
(_db_manga.get("folder_name") if _db_manga else None)
or _safe_name(manga.title_ru or manga.title)
)
manga_dir = output_dir / folder_name manga_dir = output_dir / folder_name
manga_dir.mkdir(parents=True, exist_ok=True) manga_dir.mkdir(parents=True, exist_ok=True)