This commit is contained in:
李如威 2026-01-19 15:44:58 +08:00
commit f9550c8674
7 changed files with 957 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@ -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

84
README.md Normal file
View File

@ -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`

592
download_manager.py Normal file
View File

@ -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()

106
show_status.sh Executable file
View File

@ -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

23
sources.txt Normal file
View File

@ -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

64
start.sh Executable file
View File

@ -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

50
stop.sh Executable file
View File

@ -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 "✅ 已强制停止"