diff --git a/main.py b/main.py index 4b9f0b0..23da0f4 100644 --- a/main.py +++ b/main.py @@ -4,9 +4,11 @@ import json import logging from pathlib import Path from typing import Dict, Any, List +import asyncio +import httpx import aiofiles -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles @@ -24,10 +26,15 @@ logger = logging.getLogger(__name__) DOWNLOADS_DIR = "downloads" MAX_FILENAME_LENGTH = 100 INVALID_FILENAME_CHARS = '<>:"/\\|?*' +MAX_CONCURRENT_DOWNLOADS = 5 # 最大并发下载数 +DOWNLOAD_TIMEOUT = 30 # 下载超时时间(秒) # FastAPI应用 app = FastAPI(title="eh-v2") +# 全局变量用于跟踪下载状态 +download_status: Dict[str, Dict[str, Any]] = {} + # 数据模型 class SaveDataRequest(BaseModel): url: str @@ -41,6 +48,13 @@ class GalleryInfo(BaseModel): total_images: int downloaded_images: int +class DownloadStatusResponse(BaseModel): + status: str + message: str + downloaded: int + total: int + current_progress: float + # 工具函数 def setup_downloads_directory() -> Path: """创建并返回下载目录路径""" @@ -109,42 +123,178 @@ def get_all_galleries() -> List[GalleryInfo]: return galleries -# 初始化 -downloads_path = setup_downloads_directory() +async def download_single_image(client: httpx.AsyncClient, url: str, file_path: Path, semaphore: asyncio.Semaphore) -> bool: + """下载单张图片""" + async with semaphore: + try: + logger.info(f"开始下载: {url} -> {file_path}") + + # 如果文件已存在,跳过下载 + if file_path.exists(): + logger.info(f"文件已存在,跳过: {file_path}") + return True + + # 发送请求下载图片 + async with client.stream('GET', url, timeout=DOWNLOAD_TIMEOUT) as response: + response.raise_for_status() + + # 异步写入文件 + async with aiofiles.open(file_path, 'wb') as f: + async for chunk in response.aiter_bytes(): + await f.write(chunk) + + logger.info(f"下载完成: {file_path}") + return True + + except httpx.TimeoutException: + logger.error(f"下载超时: {url}") + return False + except httpx.HTTPStatusError as e: + logger.error(f"HTTP错误 {e.response.status_code}: {url}") + return False + except Exception as e: + logger.error(f"下载失败 {url}: {e}") + return False -# API路由 -@app.post("/save_url") -async def save_url(data: SaveDataRequest): - """保存URL数据到文件系统""" +async def download_gallery_images(title: str) -> DownloadStatusResponse: + """下载指定画廊的所有图片""" + safe_title = sanitize_filename(title) + gallery_path = downloads_path / safe_title + data_file = gallery_path / "data.json" + + if not data_file.exists(): + return DownloadStatusResponse( + status="error", + message="画廊数据文件不存在", + downloaded=0, + total=0, + current_progress=0.0 + ) + try: - logger.info("收到保存数据请求") - logger.info(f"标题: {data.title}, URL: {data.url}, 图片数量: {data.total_images}") + # 读取画廊数据 + async with aiofiles.open(data_file, 'r', encoding='utf-8') as f: + content = await f.read() + data = json.loads(content) - # 创建标题目录 - title_dir = create_title_directory(downloads_path, data.title) + all_images = data.get('all_images', {}) + total_images = len(all_images) - # 数据文件路径 - data_file = title_dir / "data.json" + if total_images == 0: + return DownloadStatusResponse( + status="error", + message="没有可下载的图片", + downloaded=0, + total=0, + current_progress=0.0 + ) - # 异步保存数据 - await save_data_to_file(data_file, data.dict()) + # 初始化下载状态 + download_status[title] = { + "downloaded": 0, + "total": total_images, + "status": "downloading" + } - logger.info(f"数据已保存到: {data_file}") + logger.info(f"开始下载画廊 '{title}',共 {total_images} 张图片") - return { - "status": "success", - "message": "数据保存成功", - "file_path": str(data_file), - "title": data.title, - "total_images": data.total_images - } + # 创建信号量限制并发数 + semaphore = asyncio.Semaphore(MAX_CONCURRENT_DOWNLOADS) + # 使用异步HTTP客户端 + async with httpx.AsyncClient( + headers={ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + }, + follow_redirects=True + ) as client: + + # 准备下载任务 + tasks = [] + for filename, url in all_images.items(): + image_path = gallery_path / filename + + # 如果图片已存在,跳过下载但计入完成数量 + if image_path.exists(): + download_status[title]["downloaded"] += 1 + continue + + task = download_single_image(client, url, image_path, semaphore) + tasks.append(task) + + # 批量执行下载任务 + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 统计成功下载的数量 + successful_downloads = sum(1 for result in results if result is True) + download_status[title]["downloaded"] += successful_downloads + + # 更新最终状态 + downloaded_count = download_status[title]["downloaded"] + progress = (downloaded_count / total_images) * 100 + + if downloaded_count == total_images: + download_status[title]["status"] = "completed" + message = f"下载完成!共下载 {downloaded_count}/{total_images} 张图片" + logger.info(f"画廊 '{title}' {message}") + else: + download_status[title]["status"] = "partial" + message = f"部分完成!下载 {downloaded_count}/{total_images} 张图片" + logger.warning(f"画廊 '{title}' {message}") + + return DownloadStatusResponse( + status="success", + message=message, + downloaded=downloaded_count, + total=total_images, + current_progress=progress + ) + except Exception as e: - error_msg = f"保存数据时出错: {str(e)}" - logger.error(error_msg) - logger.exception("详细错误信息:") - raise HTTPException(status_code=500, detail=error_msg) + logger.error(f"下载画廊 '{title}' 时发生错误: {e}") + download_status[title] = { + "status": "error", + "message": str(e) + } + return DownloadStatusResponse( + status="error", + message=f"下载失败: {str(e)}", + downloaded=0, + total=0, + current_progress=0.0 + ) + +async def download_all_pending_galleries(): + """下载所有未完成的画廊""" + galleries = get_all_galleries() + pending_galleries = [g for g in galleries if g.downloaded_images < g.total_images] + + if not pending_galleries: + logger.info("没有待下载的画廊") + return + + logger.info(f"开始批量下载 {len(pending_galleries)} 个画廊") + + for gallery in pending_galleries: + if gallery.downloaded_images < gallery.total_images: + logger.info(f"开始下载画廊: {gallery.title}") + result = await download_gallery_images(gallery.title) + + if result.status == "success": + logger.info(f"画廊 '{gallery.title}' 下载完成: {result.message}") + else: + logger.error(f"画廊 '{gallery.title}' 下载失败: {result.message}") + + # 添加延迟避免请求过于频繁 + await asyncio.sleep(1) + + logger.info("批量下载任务完成") +# 初始化 +downloads_path = setup_downloads_directory() + +# API路由 @app.get("/", response_class=HTMLResponse) async def read_gallery_manager(): """画廊管理页面""" @@ -233,6 +383,14 @@ async def read_gallery_manager(): background: #c82333; transform: translateY(-2px); } + .btn-warning { + background: #ffc107; + color: #212529; + } + .btn-warning:hover { + background: #e0a800; + transform: translateY(-2px); + } .btn:disabled { background: #6c757d; cursor: not-allowed; @@ -249,7 +407,7 @@ async def read_gallery_manager(): margin-bottom: 15px; transition: all 0.3s ease; display: flex; - justify-content: space-between; + justify-content: between; align-items: center; } .gallery-item:hover { @@ -271,12 +429,8 @@ async def read_gallery_manager(): color: #6c757d; font-size: 0.9em; } - .gallery-actions { - display: flex; - gap: 10px; - } .progress-bar { - width: 200px; + width: 100%; height: 8px; background: #e9ecef; border-radius: 4px; @@ -288,6 +442,29 @@ async def read_gallery_manager(): background: linear-gradient(90deg, #28a745, #20c997); transition: width 0.3s ease; } + .completed .progress-fill { + background: linear-gradient(90deg, #007bff, #0056b3); + } + .status-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.8em; + font-weight: 600; + margin-left: 10px; + } + .status-downloading { + background: #fff3cd; + color: #856404; + } + .status-completed { + background: #d1ecf1; + color: #0c5460; + } + .status-error { + background: #f8d7da; + color: #721c24; + } .empty-state { text-align: center; padding: 60px 20px; @@ -297,6 +474,25 @@ async def read_gallery_manager(): margin-bottom: 10px; font-size: 1.5em; } + .stats-summary { + background: #f8f9fa; + padding: 15px 20px; + border-bottom: 1px solid #e9ecef; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.9em; + color: #6c757d; + } + .gallery-actions { + display: flex; + gap: 10px; + } + .download-progress { + margin-top: 10px; + font-size: 0.9em; + color: #6c757d; + }
@@ -306,21 +502,30 @@ async def read_gallery_manager():管理您的画廊下载任务
+点击"读取文件夹"按钮加载数据