init
This commit is contained in:
commit
f9550c8674
|
|
@ -0,0 +1,38 @@
|
|||
# 运行时生成的文件
|
||||
download_manager.pid
|
||||
download_state.json
|
||||
download_manager.log
|
||||
|
||||
# 下载目录
|
||||
download/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# 编辑器
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.vscode/
|
||||
.idea/
|
||||
*.sublime-*
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# 文件下载管理器
|
||||
|
||||
简单的Python文件下载工具,支持后台运行和Web界面监控。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- ✅ 顺序下载,一个完成后才开始下一个
|
||||
- ✅ 自动从URL或响应头获取文件名
|
||||
- ✅ Web界面实时查看下载状态
|
||||
- ✅ 后台运行,记录PID和日志
|
||||
- ✅ 使用Python3内置库,无需额外安装
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 配置下载地址
|
||||
|
||||
编辑 `sources.txt` 文件,一行一个URL:
|
||||
|
||||
```
|
||||
# 注释行以 # 开头
|
||||
https://example.com/file1.zip
|
||||
https://example.com/file2.pdf
|
||||
```
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
启动成功后会显示:
|
||||
- PID(进程ID)
|
||||
- Web访问地址:http://localhost:8888
|
||||
- 日志文件路径
|
||||
- 下载目录路径
|
||||
- **所有下载任务的状态统计和详情**
|
||||
|
||||
### 3. 查看状态
|
||||
|
||||
```bash
|
||||
./show_status.sh
|
||||
```
|
||||
|
||||
显示所有任务的:
|
||||
- 状态统计(等待/下载/完成/失败)
|
||||
- 每个任务的详情(URL、文件名、大小、重试次数、错误信息)
|
||||
|
||||
或者访问 Web 界面:http://localhost:8888
|
||||
|
||||
### 4. 停止服务
|
||||
|
||||
```bash
|
||||
./stop.sh
|
||||
```
|
||||
|
||||
停止前会显示当前任务状态。
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `sources.txt` | 下载资源列表(一行一个URL) |
|
||||
| `download_manager.py` | 主程序 |
|
||||
| `start.sh` | 启动脚本 |
|
||||
| `stop.sh` | 停止脚本 |
|
||||
| `show_status.sh` | 状态查看脚本 |
|
||||
| `download/` | 下载文件保存目录 |
|
||||
| `download_state.json` | 任务状态保存文件 |
|
||||
| `download_manager.log` | 运行日志 |
|
||||
| `download_manager.pid` | 进程PID文件 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 如何修改端口?**
|
||||
A: 修改 `download_manager.py` 中的 `PORT = 8888`
|
||||
|
||||
**Q: 下载中断后如何恢复?**
|
||||
A: 重新运行 `./start.sh`,会自动从中断处继续
|
||||
|
||||
**Q: 如何重新下载失败的文件?**
|
||||
A: 删除 `download_state.json` 后重启
|
||||
|
||||
**Q: 查看日志?**
|
||||
A: `tail -f download_manager.log`
|
||||
|
|
@ -0,0 +1,592 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
简单的文件下载管理器
|
||||
支持后台运行,通过Web界面查看下载状态
|
||||
"""
|
||||
import http.server
|
||||
import socketserver
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# 配置
|
||||
PORT = 8888
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DOWNLOAD_DIR = os.path.join(SCRIPT_DIR, "download")
|
||||
STATE_FILE = os.path.join(SCRIPT_DIR, "download_state.json")
|
||||
LOG_FILE = os.path.join(SCRIPT_DIR, "download_manager.log")
|
||||
PID_FILE = os.path.join(SCRIPT_DIR, "download_manager.pid")
|
||||
MAX_RETRIES = 2 # 最大重试次数
|
||||
|
||||
# 数据源配置
|
||||
SOURCES_FILE = os.path.join(SCRIPT_DIR, "sources.txt")
|
||||
|
||||
|
||||
def load_sources():
|
||||
"""从 sources.txt 文件加载下载资源"""
|
||||
sources = []
|
||||
if os.path.exists(SOURCES_FILE):
|
||||
with open(SOURCES_FILE, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
url = line.strip()
|
||||
# 跳过空行和注释行
|
||||
if url and not url.startswith('#'):
|
||||
sources.append(url)
|
||||
return sources
|
||||
|
||||
|
||||
SOURCES = load_sources()
|
||||
|
||||
|
||||
class DownloadManager:
|
||||
"""下载管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.tasks = []
|
||||
self.load_state()
|
||||
self.setup_logging()
|
||||
|
||||
def setup_logging(self):
|
||||
"""设置日志"""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_FILE, encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def load_state(self):
|
||||
"""加载状态"""
|
||||
# 每次启动都重新加载 sources.txt,检测是否有新增或删除的 URL
|
||||
current_sources = load_sources()
|
||||
|
||||
if os.path.exists(STATE_FILE):
|
||||
try:
|
||||
with open(STATE_FILE, 'r', encoding='utf-8') as f:
|
||||
self.tasks = json.load(f)
|
||||
|
||||
# 检查 sources 是否有变化,有变化则重新初始化
|
||||
existing_urls = {task['url'] for task in self.tasks}
|
||||
if set(current_sources) != existing_urls:
|
||||
print("检测到 sources.txt 有变化,重新初始化任务列表")
|
||||
self.tasks = [{"url": url, "status": "pending", "filename": "", "size": 0, "error": "", "retry": 0} for url in current_sources]
|
||||
self.save_state()
|
||||
except Exception as e:
|
||||
print(f"加载状态失败: {e}")
|
||||
self.tasks = [{"url": url, "status": "pending", "filename": "", "size": 0, "error": "", "retry": 0} for url in current_sources]
|
||||
else:
|
||||
# 初始化任务列表
|
||||
self.tasks = [{"url": url, "status": "pending", "filename": "", "size": 0, "error": "", "retry": 0} for url in current_sources]
|
||||
|
||||
def save_state(self):
|
||||
"""保存状态"""
|
||||
try:
|
||||
with open(STATE_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.tasks, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
self.logger.error(f"保存状态失败: {e}")
|
||||
|
||||
def get_filename_from_url(self, url, response):
|
||||
"""从URL或响应头获取文件名"""
|
||||
# 尝试从Content-Disposition获取
|
||||
content_disp = response.headers.get('Content-Disposition', '')
|
||||
if content_disp:
|
||||
import re
|
||||
match = re.search(r'filename[*]?=["\']?([^"\';\s]+)["\']?', content_disp)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# 从URL路径提取
|
||||
from urllib.parse import unquote, urlparse
|
||||
path = urlparse(url).path
|
||||
filename = os.path.basename(unquote(path))
|
||||
if filename:
|
||||
return filename
|
||||
|
||||
# 默认文件名
|
||||
return f"file_{int(time.time())}"
|
||||
|
||||
def download_file(self, task):
|
||||
"""下载单个文件(带重试机制)"""
|
||||
url = task['url']
|
||||
retry_count = task.get('retry', 0)
|
||||
|
||||
try:
|
||||
self.logger.info(f"开始下载: {url}" + (f" (重试 {retry_count}/{MAX_RETRIES})" if retry_count > 0 else ""))
|
||||
|
||||
# 创建下载目录
|
||||
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
||||
|
||||
# 先获取文件名和预期大小
|
||||
req = urllib.request.Request(url, method='HEAD')
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
filename = self.get_filename_from_url(url, response)
|
||||
except:
|
||||
# HEAD请求失败,使用GET请求获取文件名
|
||||
with urllib.request.urlopen(url) as response:
|
||||
filename = self.get_filename_from_url(url, response)
|
||||
|
||||
filepath = os.path.join(DOWNLOAD_DIR, filename)
|
||||
|
||||
# 检查文件是否已存在且完整
|
||||
if self.check_file_complete(filepath, url):
|
||||
task['status'] = 'completed'
|
||||
task['filename'] = filename
|
||||
task['size'] = os.path.getsize(filepath)
|
||||
task['error'] = ''
|
||||
self.logger.info(f"文件已存在且完整,跳过下载: {filename}")
|
||||
self.save_state()
|
||||
return True
|
||||
|
||||
# 下载文件
|
||||
task['status'] = 'downloading'
|
||||
task['filename'] = filename
|
||||
self.save_state()
|
||||
|
||||
with urllib.request.urlopen(url) as response:
|
||||
# 获取预期文件大小
|
||||
expected_size = response.headers.get('Content-Length')
|
||||
if expected_size:
|
||||
expected_size = int(expected_size)
|
||||
|
||||
downloaded = 0
|
||||
with open(filepath, 'wb') as f:
|
||||
while True:
|
||||
chunk = response.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
# 验证文件完整性
|
||||
if expected_size and downloaded != expected_size:
|
||||
os.remove(filepath)
|
||||
raise IOError(f"文件不完整: 预期 {expected_size} 字节,实际 {downloaded} 字节")
|
||||
|
||||
task['size'] = downloaded
|
||||
task['status'] = 'completed'
|
||||
task['error'] = ''
|
||||
self.logger.info(f"下载完成: {filename} ({downloaded} bytes)")
|
||||
self.save_state()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
retry_count = task.get('retry', 0) + 1
|
||||
task['retry'] = retry_count
|
||||
error_msg = str(e)
|
||||
|
||||
if retry_count <= MAX_RETRIES:
|
||||
task['status'] = 'retrying'
|
||||
task['error'] = f'{error_msg} (准备第 {retry_count} 次重试)'
|
||||
self.logger.warning(f"下载失败: {url} - {error_msg},准备第 {retry_count} 次重试")
|
||||
self.save_state()
|
||||
time.sleep(2) # 等待2秒后重试
|
||||
return self.download_file(task)
|
||||
else:
|
||||
task['status'] = 'failed'
|
||||
task['error'] = f'{error_msg} (已重试 {MAX_RETRIES} 次)'
|
||||
self.logger.error(f"下载失败: {url} - {error_msg} (已重试 {MAX_RETRIES} 次)")
|
||||
self.save_state()
|
||||
return False
|
||||
|
||||
def check_file_complete(self, filepath, url):
|
||||
"""检查文件是否已存在且完整"""
|
||||
if not os.path.exists(filepath):
|
||||
return False
|
||||
|
||||
# 获取已下载文件的大小
|
||||
local_size = os.path.getsize(filepath)
|
||||
|
||||
# 尝试获取远程文件大小
|
||||
try:
|
||||
req = urllib.request.Request(url, method='HEAD')
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
remote_size = response.headers.get('Content-Length')
|
||||
if remote_size:
|
||||
remote_size = int(remote_size)
|
||||
# 文件存在且大小一致
|
||||
if local_size == remote_size:
|
||||
return True
|
||||
return False
|
||||
except:
|
||||
# 无法获取远程文件大小,检查本地文件是否大于0
|
||||
return local_size > 0
|
||||
|
||||
return local_size > 0
|
||||
|
||||
def run_downloads(self):
|
||||
"""执行所有下载任务"""
|
||||
self.logger.info("=" * 50)
|
||||
self.logger.info("开始下载任务")
|
||||
|
||||
for task in self.tasks:
|
||||
if task['status'] in ['pending', 'downloading', 'retrying']:
|
||||
self.download_file(task)
|
||||
|
||||
self.logger.info("所有下载任务完成")
|
||||
self.logger.info("=" * 50)
|
||||
|
||||
|
||||
class WebHandler(http.server.SimpleHTTPRequestHandler):
|
||||
"""Web请求处理器"""
|
||||
|
||||
def __init__(self, *args, manager=None, **kwargs):
|
||||
self.manager = manager
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def do_GET(self):
|
||||
"""处理GET请求"""
|
||||
if self.path == '/' or self.path == '/status':
|
||||
self.send_html()
|
||||
elif self.path == '/api/tasks':
|
||||
self.send_json(self.manager.tasks)
|
||||
elif self.path.startswith('/api/log'):
|
||||
self.send_log()
|
||||
else:
|
||||
super().do_GET()
|
||||
|
||||
def send_html(self):
|
||||
"""发送HTML页面"""
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>下载管理器</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; }
|
||||
.container { max-width: 1400px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); overflow: hidden; }
|
||||
.header { background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%); color: white; padding: 25px 30px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 24px; font-weight: 600; }
|
||||
.header-controls { display: flex; gap: 10px; align-items: center; }
|
||||
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.3s; }
|
||||
.btn:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
|
||||
.btn-primary { background: white; color: #4CAF50; }
|
||||
.btn-secondary { background: rgba(255,255,255,0.2); color: white; }
|
||||
.btn-secondary:hover { background: rgba(255,255,255,0.3); }
|
||||
.refresh-badge { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: rgba(255,255,255,0.2); border-radius: 20px; font-size: 13px; }
|
||||
.refresh-badge.active { background: rgba(76, 175, 80, 0.9); }
|
||||
.refresh-badge.paused { background: rgba(255, 152, 0, 0.9); }
|
||||
.spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||
.paused .spinner { animation: none; border: 2px solid rgba(255,255,255,0.5); }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.info-bar { background: #f8f9fa; padding: 15px 30px; border-bottom: 1px solid #e9ecef; display: flex; flex-wrap: wrap; gap: 20px; align-items: center; font-size: 14px; }
|
||||
.info-item { display: flex; align-items: center; gap: 8px; }
|
||||
.info-item code { background: #e9ecef; padding: 3px 8px; border-radius: 4px; font-size: 12px; }
|
||||
.stats { display: flex; gap: 20px; margin-left: auto; }
|
||||
.stat-badge { padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; }
|
||||
.stat-badge.pending { background: #fff3cd; color: #856404; }
|
||||
.stat-badge.downloading { background: #cce5ff; color: #004085; }
|
||||
.stat-badge.retrying { background: #ffe8cc; color: #d97706; }
|
||||
.stat-badge.completed { background: #d4edda; color: #155724; }
|
||||
.stat-badge.failed { background: #f8d7da; color: #721c24; }
|
||||
.table-container { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #f8f9fa; padding: 15px; text-align: left; font-weight: 600; font-size: 13px; color: #495057; border-bottom: 2px solid #dee2e6; white-space: nowrap; }
|
||||
td { padding: 12px 15px; border-bottom: 1px solid #e9ecef; font-size: 14px; }
|
||||
tr:hover { background: #f8f9fa; }
|
||||
.status { padding: 4px 10px; border-radius: 4px; font-size: 12px; font-weight: 600; display: inline-block; }
|
||||
.status.pending { background: #fff3cd; color: #856404; }
|
||||
.status.downloading { background: #cce5ff; color: #004085; animation: pulse 2s infinite; }
|
||||
.status.retrying { background: #ffe8cc; color: #d97706; animation: pulse 2s infinite; }
|
||||
.status.completed { background: #d4edda; color: #155724; }
|
||||
.status.failed { background: #f8d7da; color: #721c24; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
|
||||
.error { color: #dc3545; font-size: 12px; max-width: 300px; display: block; }
|
||||
.url-link { color: #4CAF50; text-decoration: none; max-width: 400px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.url-link:hover { text-decoration: underline; }
|
||||
.log-section { padding: 20px 30px; background: #f8f9fa; border-top: 1px solid #e9ecef; }
|
||||
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.log-header h3 { font-size: 16px; color: #495057; }
|
||||
.last-update { font-size: 12px; color: #6c757d; }
|
||||
pre { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 8px; overflow-x: auto; max-height: 300px; font-size: 12px; line-height: 1.5; }
|
||||
.auto-refresh-toggle { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||
.toggle-switch { position: relative; width: 44px; height: 24px; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: 0.3s; border-radius: 24px; }
|
||||
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
|
||||
input:checked + .slider { background-color: #4CAF50; }
|
||||
input:checked + .slider:before { transform: translateX(20px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📥 文件下载管理器</h1>
|
||||
<div class="header-controls">
|
||||
<div class="auto-refresh-toggle">
|
||||
<span>自动刷新</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="autoRefresh" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="refresh-badge active" id="refreshBadge">
|
||||
<div class="spinner"></div>
|
||||
<span id="refreshStatus">3秒</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="refreshNow()">🔄 立即刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-bar">
|
||||
<div class="info-item">
|
||||
<strong>下载目录:</strong>
|
||||
<code>""" + os.path.basename(DOWNLOAD_DIR) + """</code>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>日志:</strong>
|
||||
<code>""" + os.path.basename(LOG_FILE) + """</code>
|
||||
</div>
|
||||
<div class="stats" id="stats"></div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>URL</th>
|
||||
<th>状态</th>
|
||||
<th>重试</th>
|
||||
<th>文件名</th>
|
||||
<th>大小</th>
|
||||
<th>错误信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tasklist"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="log-section">
|
||||
<div class="log-header">
|
||||
<h3>📋 最近日志</h3>
|
||||
<span class="last-update">更新于: <span id="lastUpdate">--:--:--</span></span>
|
||||
</div>
|
||||
<pre id="log">加载中...</pre>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const MAX_RETRIES = """ + str(MAX_RETRIES) + """;
|
||||
const REFRESH_INTERVAL = 3000;
|
||||
let autoRefresh = true;
|
||||
let refreshTimer = null;
|
||||
let countdownTimer = null;
|
||||
let countdown = REFRESH_INTERVAL / 1000;
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes === 0) return '-';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
return 'status ' + status;
|
||||
}
|
||||
|
||||
function getStatusText(status) {
|
||||
const map = {
|
||||
'pending': '⏳ 等待中',
|
||||
'downloading': '⬇️ 下载中',
|
||||
'retrying': '🔄 重试中',
|
||||
'completed': '✅ 完成',
|
||||
'failed': '❌ 失败',
|
||||
'skipped': '⏭️ 已跳过'
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
function updateStats(tasks) {
|
||||
const stats = { pending: 0, downloading: 0, retrying: 0, completed: 0, failed: 0 };
|
||||
tasks.forEach(t => stats[t.status] = (stats[t.status] || 0) + 1);
|
||||
|
||||
const statsHtml = Object.entries(stats)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.map(([status, count]) => `<span class="stat-badge ${status}">${getStatusText(status)}: ${count}</span>`)
|
||||
.join('');
|
||||
document.getElementById('stats').innerHTML = statsHtml;
|
||||
}
|
||||
|
||||
function loadTasks() {
|
||||
return fetch('/api/tasks')
|
||||
.then(r => r.json())
|
||||
.then(tasks => {
|
||||
const tbody = document.getElementById('tasklist');
|
||||
tbody.innerHTML = tasks.map((t, i) => `
|
||||
<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td><a href="${t.url}" target="_blank" class="url-link">${t.url}</a></td>
|
||||
<td><span class="${getStatusClass(t.status)}">${getStatusText(t.status)}</span></td>
|
||||
<td>${(t.retry || 0) > 0 ? '<span style="color:#d97706;font-weight:bold;">' + (t.retry || 0) + ' / ' + MAX_RETRIES + '</span>' : '-'}</td>
|
||||
<td>${t.filename || '-'}</td>
|
||||
<td>${formatSize(t.size)}</td>
|
||||
<td>${t.error ? `<span class="error" title="${t.error}">${t.error}</span>` : '-'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
updateStats(tasks);
|
||||
});
|
||||
}
|
||||
|
||||
function loadLog() {
|
||||
return fetch('/api/log')
|
||||
.then(r => r.text())
|
||||
.then(log => {
|
||||
document.getElementById('log').textContent = log || '暂无日志';
|
||||
});
|
||||
}
|
||||
|
||||
function updateLastUpdateTime() {
|
||||
const now = new Date();
|
||||
const time = now.toLocaleTimeString('zh-CN', { hour12: false });
|
||||
document.getElementById('lastUpdate').textContent = time;
|
||||
}
|
||||
|
||||
function refreshNow() {
|
||||
loadTasks();
|
||||
loadLog();
|
||||
updateLastUpdateTime();
|
||||
countdown = REFRESH_INTERVAL / 1000;
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown--;
|
||||
if (countdown <= 0) countdown = REFRESH_INTERVAL / 1000;
|
||||
document.getElementById('refreshStatus').textContent = countdown + '秒';
|
||||
}, 1000);
|
||||
|
||||
refreshTimer = setInterval(() => {
|
||||
if (autoRefresh) {
|
||||
refreshNow();
|
||||
}
|
||||
}, REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
}
|
||||
|
||||
document.getElementById('autoRefresh').addEventListener('change', function(e) {
|
||||
autoRefresh = e.target.checked;
|
||||
const badge = document.getElementById('refreshBadge');
|
||||
if (autoRefresh) {
|
||||
badge.classList.remove('paused');
|
||||
badge.classList.add('active');
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
badge.classList.remove('active');
|
||||
badge.classList.add('paused');
|
||||
stopAutoRefresh();
|
||||
document.getElementById('refreshStatus').textContent = '已暂停';
|
||||
}
|
||||
});
|
||||
|
||||
// 初始加载
|
||||
refreshNow();
|
||||
startAutoRefresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||||
self.end_headers()
|
||||
self.wfile.write(html.encode('utf-8'))
|
||||
|
||||
def send_json(self, data):
|
||||
"""发送JSON响应"""
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
|
||||
|
||||
def send_log(self):
|
||||
"""发送日志内容"""
|
||||
try:
|
||||
if os.path.exists(LOG_FILE):
|
||||
with open(LOG_FILE, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
# 返回最后50行
|
||||
log_content = ''.join(lines[-50:])
|
||||
else:
|
||||
log_content = '日志文件不存在'
|
||||
except Exception as e:
|
||||
log_content = f'读取日志失败: {e}'
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
||||
self.end_headers()
|
||||
self.wfile.write(log_content.encode('utf-8'))
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""禁用默认日志"""
|
||||
pass
|
||||
|
||||
|
||||
def save_pid(pid):
|
||||
"""保存PID"""
|
||||
with open(PID_FILE, 'w') as f:
|
||||
f.write(str(pid))
|
||||
|
||||
|
||||
def run_server(manager):
|
||||
"""运行Web服务器"""
|
||||
def handler(*args, **kwargs):
|
||||
return WebHandler(*args, manager=manager, **kwargs)
|
||||
|
||||
with socketserver.TCPServer(("", PORT), handler) as httpd:
|
||||
print(f"\n🌐 Web服务器启动: http://localhost:{PORT}")
|
||||
print(f"📁 下载目录: {os.path.abspath(DOWNLOAD_DIR)}")
|
||||
print(f"📋 日志文件: {os.path.abspath(LOG_FILE)}")
|
||||
print(f"📄 PID文件: {os.path.abspath(PID_FILE)}\n")
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 检查是否已有进程运行
|
||||
if os.path.exists(PID_FILE):
|
||||
try:
|
||||
with open(PID_FILE, 'r') as f:
|
||||
old_pid = int(f.read().strip())
|
||||
# 检查进程是否存在
|
||||
try:
|
||||
os.kill(old_pid, 0)
|
||||
print(f"⚠️ 进程已在运行 (PID: {old_pid})")
|
||||
print("如需重启,请先运行: ./stop.sh")
|
||||
return
|
||||
except OSError:
|
||||
# 进程不存在,删除旧的PID文件
|
||||
os.remove(PID_FILE)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 保存当前PID
|
||||
save_pid(os.getpid())
|
||||
|
||||
# 创建管理器
|
||||
manager = DownloadManager()
|
||||
|
||||
# 在后台线程执行下载
|
||||
import threading
|
||||
download_thread = threading.Thread(target=manager.run_downloads, daemon=True)
|
||||
download_thread.start()
|
||||
|
||||
# 启动Web服务器
|
||||
run_server(manager)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
#!/bin/bash
|
||||
# 显示下载任务状态
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
STATE_FILE="download_state.json"
|
||||
|
||||
# 检查状态文件是否存在
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
echo "⚠️ 状态文件不存在,服务可能尚未初始化"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 使用 Python 解析 JSON 并显示状态
|
||||
python3 << 'EOF'
|
||||
import json
|
||||
import os
|
||||
|
||||
STATE_FILE = "download_state.json"
|
||||
|
||||
try:
|
||||
with open(STATE_FILE, 'r', encoding='utf-8') as f:
|
||||
tasks = json.load(f)
|
||||
|
||||
if not tasks:
|
||||
print("📋 暂无下载任务")
|
||||
exit(0)
|
||||
|
||||
# 统计状态
|
||||
status_count = {}
|
||||
for task in tasks:
|
||||
status = task.get('status', 'unknown')
|
||||
status_count[status] = status_count.get(status, 0) + 1
|
||||
|
||||
# 显示统计
|
||||
total = len(tasks)
|
||||
print(f"📊 总计: {total} 个任务")
|
||||
print()
|
||||
|
||||
status_map = {
|
||||
'pending': ('⏳ 等待中', 'yellow'),
|
||||
'downloading': ('⬇️ 下载中', 'blue'),
|
||||
'retrying': ('🔄 重试中', 'orange'),
|
||||
'completed': ('✅ 已完成', 'green'),
|
||||
'failed': ('❌ 失败', 'red'),
|
||||
}
|
||||
|
||||
for status, (label, _) in status_map.items():
|
||||
count = status_count.get(status, 0)
|
||||
if count > 0:
|
||||
print(f" {label}: {count}")
|
||||
|
||||
print()
|
||||
print("📋 任务详情:")
|
||||
print("-" * 80)
|
||||
|
||||
# 显示每个任务
|
||||
for i, task in enumerate(tasks, 1):
|
||||
url = task.get('url', '')
|
||||
status = task.get('status', 'unknown')
|
||||
filename = task.get('filename', '')
|
||||
size = task.get('size', 0)
|
||||
error = task.get('error', '')
|
||||
retry = task.get('retry', 0)
|
||||
|
||||
# 状态图标
|
||||
status_icons = {
|
||||
'pending': '⏳',
|
||||
'downloading': '⬇️ ',
|
||||
'retrying': '🔄',
|
||||
'completed': '✅',
|
||||
'failed': '❌',
|
||||
}
|
||||
icon = status_icons.get(status, '❓')
|
||||
|
||||
# 格式化大小
|
||||
def format_size(bytes_size):
|
||||
if bytes_size == 0:
|
||||
return '-'
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if bytes_size < 1024:
|
||||
return f"{bytes_size:.2f} {unit}"
|
||||
bytes_size /= 1024
|
||||
return f"{bytes_size:.2f} TB"
|
||||
|
||||
# URL 缩短显示
|
||||
short_url = url if len(url) <= 60 else url[:57] + "..."
|
||||
|
||||
print(f"{i:2d}. {icon} {status}")
|
||||
print(f" URL: {short_url}")
|
||||
if filename:
|
||||
print(f" 文件: {filename} ({format_size(size)})")
|
||||
if retry > 0:
|
||||
print(f" 重试: {retry} 次")
|
||||
if error:
|
||||
print(f" 错误: {error}")
|
||||
print()
|
||||
|
||||
except FileNotFoundError:
|
||||
print("⚠️ 状态文件不存在")
|
||||
except json.JSONDecodeError:
|
||||
print("⚠️ 状态文件格式错误")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取状态失败: {e}")
|
||||
EOF
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# 下载资源列表
|
||||
# 一行一个URL,以 # 开头的行会被忽略(注释)
|
||||
|
||||
# 测试用的小文件(来自 httpbin.org)
|
||||
https://httpbin.org/json
|
||||
https://httpbin.org/uuid
|
||||
https://httpbin.org/ip
|
||||
https://httpbin.org/user-agent
|
||||
https://httpbin.org/headers
|
||||
https://httpbin.org/get
|
||||
https://httpbin.org/brotli
|
||||
https://httpbin.org/status/200
|
||||
https://httpbin.org/encoding/utf8
|
||||
https://httpbin.org/gzip
|
||||
https://httpbin.org/deflate
|
||||
https://httpbin.org/robots.txt
|
||||
https://httpbin.org/delay/0
|
||||
https://httpbin.org/deny
|
||||
https://httpbin.org/html
|
||||
https://httpbin.org/xml
|
||||
https://httpbin.org/image/png
|
||||
https://httpbin.org/image/jpeg
|
||||
https://httpbin.org/image/webp
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
#!/bin/bash
|
||||
# 启动下载管理器
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "================================"
|
||||
echo " 启动下载管理器"
|
||||
echo "================================"
|
||||
|
||||
# 检查Python3
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "❌ 错误: 未找到 python3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否已运行
|
||||
if [ -f "download_manager.pid" ]; then
|
||||
OLD_PID=$(cat download_manager.pid)
|
||||
if ps -p "$OLD_PID" > /dev/null 2>&1; then
|
||||
echo "⚠️ 下载管理器已在运行 (PID: $OLD_PID)"
|
||||
echo ""
|
||||
./show_status.sh
|
||||
echo ""
|
||||
echo "如需重启,请先运行: ./stop.sh"
|
||||
exit 1
|
||||
else
|
||||
echo "清理旧的PID文件..."
|
||||
rm -f download_manager.pid
|
||||
fi
|
||||
fi
|
||||
|
||||
# 启动服务
|
||||
echo "启动服务中..."
|
||||
nohup python3 download_manager.py > /dev/null 2>&1 &
|
||||
|
||||
# 等待启动
|
||||
sleep 2
|
||||
|
||||
# 检查是否成功
|
||||
if [ -f "download_manager.pid" ]; then
|
||||
PID=$(cat download_manager.pid)
|
||||
if ps -p "$PID" > /dev/null 2>&1; then
|
||||
echo "✅ 启动成功!"
|
||||
echo ""
|
||||
echo " PID: $PID"
|
||||
echo " Web: http://localhost:8888"
|
||||
echo " 日志: download_manager.log"
|
||||
echo " 下载目录: download/"
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo " 下载任务状态"
|
||||
echo "================================"
|
||||
./show_status.sh
|
||||
echo ""
|
||||
echo "使用 ./stop.sh 停止服务"
|
||||
else
|
||||
echo "❌ 启动失败,请查看日志: download_manager.log"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ 启动失败,请检查 download_manager.log"
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
#!/bin/bash
|
||||
# 停止下载管理器
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "================================"
|
||||
echo " 停止下载管理器"
|
||||
echo "================================"
|
||||
|
||||
PID_FILE="download_manager.pid"
|
||||
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo "⚠️ PID文件不存在,服务可能未运行"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PID=$(cat "$PID_FILE")
|
||||
|
||||
# 检查进程是否存在
|
||||
if ! ps -p "$PID" > /dev/null 2>&1; then
|
||||
echo "⚠️ 进程 $PID 不存在"
|
||||
rm -f "$PID_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 显示停止前的状态
|
||||
echo ""
|
||||
./show_status.sh
|
||||
echo ""
|
||||
|
||||
# 停止进程
|
||||
echo "停止进程 $PID ..."
|
||||
kill "$PID"
|
||||
|
||||
# 等待进程结束
|
||||
for i in {1..10}; do
|
||||
if ! ps -p "$PID" > /dev/null 2>&1; then
|
||||
echo "✅ 已成功停止"
|
||||
rm -f "$PID_FILE"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 强制结束
|
||||
echo "强制停止进程..."
|
||||
kill -9 "$PID"
|
||||
rm -f "$PID_FILE"
|
||||
echo "✅ 已强制停止"
|
||||
Loading…
Reference in New Issue