upd
This commit is contained in:
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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>
|
||||||
|
|||||||
131
src/api.py
131
src/api.py
@@ -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
|
||||||
|
|||||||
27
src/state.py
27
src/state.py
@@ -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=?
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user