diff --git a/frontend/index.html b/frontend/index.html index d462624..f19c438 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -192,6 +192,58 @@ + + + + + + diff --git a/src/api.py b/src/api.py index 15742ac..e845495 100644 --- a/src/api.py +++ b/src/api.py @@ -55,6 +55,13 @@ download_queue: asyncio.Queue = asyncio.Queue() 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(): """Последовательно обрабатывает очередь загрузок. Перезапускается при краше.""" while True: @@ -84,9 +91,12 @@ async def _queue_worker_loop(): if skip: download_queue.task_done() + await _broadcast_queue_positions() continue logger.info("Воркер: начинаю скачивать {}", url) + # Позиции изменились — уведомляем клиентов + await _broadcast_queue_positions() dl_task = asyncio.create_task(download_manga( url=url, fmt=fmt, @@ -116,6 +126,7 @@ async def _queue_worker_loop(): finally: active_tasks.pop(url, None) download_queue.task_done() + await _broadcast_queue_positions() @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: """Размер директории в байтах.""" if not path.exists(): @@ -203,9 +227,7 @@ def _format_size(bytes_val: int) -> str: def _enrich_manga(m: dict, db: StateDB) -> dict: """Обогащает строку манги реальными счётчиками из таблицы chapters.""" - title = m.get("title") or "" - safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80] - size_bytes = _dir_size(OUTPUT_DIR / safe_title) + size_bytes = _dir_size(_manga_folder(m)) ch_done_count = db.conn.execute( "SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'", (m["url"],) @@ -238,9 +260,7 @@ def _manga_detail(manga: dict, db: StateDB) -> dict: chapters = db.get_all_chapters(url) # Определяем директорию манги - title = manga.get("title") or "" - safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80] - manga_dir = OUTPUT_DIR / safe_title + manga_dir = _manga_folder(manga) size_bytes = _dir_size(manga_dir) # Файлы @@ -361,6 +381,7 @@ async def add_to_queue(body: AddMangaRequest): "url": url, "format": body.format, }) + await _broadcast_queue_positions() # Запускаем фоновую задачу предпросмотра (без Chromium — быстро) asyncio.create_task(_fetch_preview(url)) else: @@ -525,6 +546,7 @@ async def prioritize_manga(url: str): "url": url, "preempted_url": current_url, }) + await _broadcast_queue_positions() return {"ok": True} finally: db.close() @@ -619,6 +641,96 @@ async def _do_refresh_meta(url: str): 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") async def force_redownload(url: str): """Сбросить все главы на pending и поставить мангу заново в очередь.""" @@ -641,6 +753,7 @@ async def force_redownload(url: str): db.update_manga_status(url, "queued") await download_queue.put({"url": url, "fmt": manga["format"], "resume": False}) await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]}) + await _broadcast_queue_positions() return {"ok": True} finally: db.close() @@ -664,6 +777,7 @@ async def stop_manga(url: str): # Манга в очереди (ещё не начата) — просто помечаем как stopped db.update_manga_status(url, "stopped") await ws_manager.broadcast({"type": "manga_stopped", "url": url}) + await _broadcast_queue_positions() return {"ok": True} finally: @@ -684,6 +798,7 @@ async def resume_manga(url: str): db.update_manga_status(url, "queued") await download_queue.put({"url": url, "fmt": manga["format"]}) await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]}) + await _broadcast_queue_positions() return {"ok": True} finally: db.close() @@ -701,9 +816,7 @@ async def delete_manga(url: str, delete_files: bool = False): deleted_size = 0 if delete_files: - title = manga.get("title") or "" - safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80] - manga_dir = OUTPUT_DIR / safe_title + manga_dir = _manga_folder(manga) if manga_dir.exists() and manga_dir.is_dir(): deleted_size = _dir_size(manga_dir) import shutil diff --git a/src/state.py b/src/state.py index 48918bf..773100b 100644 --- a/src/state.py +++ b/src/state.py @@ -79,6 +79,7 @@ class StateDB: ("mangas", "last_checked_at", "TEXT"), ("mangas", "started_at", "TEXT"), ("mangas", "finished_at", "TEXT"), + ("mangas", "folder_name", "TEXT"), ] for table, col, typedef in migrations: try: @@ -110,6 +111,32 @@ class StateDB: """, (title, title_ru, title_full, pub_status, chapters_total, _now(), url)) 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): self.conn.execute(""" UPDATE mangas SET auto_update=?, updated_at=? WHERE url=? diff --git a/src/worker.py b/src/worker.py index eb535cd..202781d 100644 --- a/src/worker.py +++ b/src/worker.py @@ -92,7 +92,12 @@ async def download_manga( "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.mkdir(parents=True, exist_ok=True)