Files
Air_Engine/air2.py
T
2026-04-11 13:51:40 +08:00

1640 lines
70 KiB
Python

import os
import sys
import time
import json
import signal
import threading
import hashlib
import concurrent.futures
import zipfile
import argparse
from pathlib import Path
from typing import Optional, Dict, List, Tuple, Callable, Any
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from urllib.parse import urlparse
from enum import Enum
import queue
import math
import logging
# 第三方库
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from rich.console import Console
from rich.progress import (
Progress,
TextColumn,
BarColumn,
DownloadColumn,
TransferSpeedColumn,
TimeRemainingColumn,
TaskID
)
from rich.table import Table
from rich.panel import Panel
from rich.live import Live
from rich.text import Text
from rich.prompt import Prompt, Confirm
from rich.style import Style
from rich.theme import Theme
# 新的现代化CLI依赖
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import Completer, Completion, WordCompleter
from prompt_toolkit.styles import Style as PtStyle
from prompt_toolkit.key_binding import KeyBindings
PROMPT_TOOLKIT_AVAILABLE = True
except ImportError:
PROMPT_TOOLKIT_AVAILABLE = False
print("提示: 安装 prompt_toolkit 可获得命令补全和历史记录功能")
print("建议执行: pip install prompt_toolkit")
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("air2.log"),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger("air2")
class DownloadStatus(Enum):
WAITING = "等待"
DOWNLOADING = "下载中"
PAUSED = "已暂停"
COMPLETED = "已完成"
ERROR = "错误"
CANCELED = "已取消"
@dataclass
class DownloadTask:
"""下载任务数据结构"""
url: str
filepath: Path
filesize: int = 0
downloaded: int = 0
status: DownloadStatus = DownloadStatus.WAITING
chunks: Dict[int, Tuple[int, int]] = field(default_factory=dict)
threads: int = 8
max_connections: int = 16
chunk_size: int = 1024 * 1024 # 动态调整,初始化时设置
timeout: int = 30
retry_count: int = 5
speed_limit: int = 0
checksum: str = ""
metadata: Dict = field(default_factory=dict)
created_at: datetime = field(default_factory=datetime.now)
error_msg: str = ""
start_time: Optional[datetime] = None
last_update: datetime = field(default_factory=datetime.now)
avg_speed: float = 0.0
current_speed: float = 0.0
eta: timedelta = field(default_factory=timedelta)
@property
def progress(self) -> float:
if self.filesize == 0:
return 0.0
return (self.downloaded / self.filesize) * 100
def update_eta(self):
if self.status != DownloadStatus.DOWNLOADING or self.current_speed <= 0:
self.eta = timedelta(seconds=0)
return
remaining_bytes = self.filesize - self.downloaded
if remaining_bytes <= 0:
self.eta = timedelta(seconds=0)
return
remaining_kb = remaining_bytes / 1024
seconds = remaining_kb / self.current_speed
self.eta = timedelta(seconds=int(seconds))
class ChunkDownloader:
"""分块下载器 - 优化版"""
def __init__(self, task: DownloadTask, chunk_id: int, start: int, end: int):
self.task = task
self.chunk_id = chunk_id
self.start = start
self.end = end
self.position = start
self.downloaded = 0
self.running = False
self.session = None
def download(self, session: requests.Session, progress_queue: queue.Queue):
"""下载指定分块(大缓冲区优化)"""
self.running = True
self.session = session
headers = {'Range': f'bytes={self.position}-{self.end}'}
retry_count = 0
last_progress_time = time.time()
bytes_since_last_progress = 0
PROGRESS_INTERVAL_SEC = 1.0 # 每秒最多一次
PROGRESS_INTERVAL_BYTES = 5 * 1024 * 1024 # 或每5MB
while retry_count <= self.task.retry_count and self.running:
try:
logger.debug(f"开始下载分块 {self.chunk_id}, 范围: {self.position}-{self.end}")
response = session.get(
self.task.url,
headers=headers,
stream=True,
timeout=(self.task.timeout, self.task.timeout),
verify=False
)
response.raise_for_status()
content_range = response.headers.get('Content-Range', '')
if not content_range and self.position > 0:
logger.warning(f"服务器不支持范围请求,重新请求整个文件")
headers = {'Range': f'bytes=0-{self.end}'}
self.position = 0
continue
mode = 'rb+' if self.position > 0 else 'wb'
with open(self.task.filepath, mode) as f:
f.seek(self.position)
# 使用 1MB 缓冲区减少系统调用
for chunk in response.iter_content(chunk_size=1024 * 1024):
if not self.running:
logger.info(f"分块 {self.chunk_id} 被暂停")
break
if chunk:
f.write(chunk)
chunk_len = len(chunk)
self.position += chunk_len
self.downloaded += chunk_len
bytes_since_last_progress += chunk_len
# 限速控制
if self.task.speed_limit > 0:
time.sleep(chunk_len / (self.task.speed_limit * 1024))
# 智能进度报告
now = time.time()
if (now - last_progress_time >= PROGRESS_INTERVAL_SEC or
bytes_since_last_progress >= PROGRESS_INTERVAL_BYTES):
progress_queue.put((self.task.url, self.chunk_id, self.downloaded))
last_progress_time = now
bytes_since_last_progress = 0
progress_queue.put((self.task.url, self.chunk_id, self.downloaded))
if self.running and self.position >= self.end + 1:
logger.info(f"分块 {self.chunk_id} 下载完成")
return True
elif not self.running:
logger.info(f"分块 {self.chunk_id} 暂停在位置 {self.position}")
return False
else:
logger.warning(f"分块 {self.chunk_id} 未完整下载: {self.position}/{self.end + 1}")
retry_count += 1
time.sleep(2 ** min(retry_count, 5))
except requests.exceptions.RequestException as e:
logger.error(f"分块 {self.chunk_id} 请求失败: {str(e)}")
retry_count += 1
if retry_count > self.task.retry_count:
progress_queue.put(('error', self.task.url, f"分块{self.chunk_id}: {str(e)}"))
return False
time.sleep(2 ** min(retry_count, 5))
except IOError as e:
logger.error(f"分块 {self.chunk_id} 文件IO错误: {str(e)}")
progress_queue.put(('error', self.task.url, f"分块{self.chunk_id}: {str(e)}"))
return False
except Exception as e:
logger.exception(f"分块 {self.chunk_id} 未知错误")
progress_queue.put(('error', self.task.url, f"分块{self.chunk_id}: {str(e)}"))
return False
return False
def pause(self):
self.running = False
if self.session:
try:
self.session.close()
except:
pass
class ProtocolHandler:
@staticmethod
def supports(url: str) -> bool:
raise NotImplementedError
@staticmethod
def get_info(url: str, session: requests.Session) -> Tuple[int, str, Dict]:
raise NotImplementedError
class HTTPHandler(ProtocolHandler):
@staticmethod
def supports(url: str) -> bool:
parsed = urlparse(url)
return parsed.scheme in ['http', 'https']
@staticmethod
def get_info(url: str, session: requests.Session) -> Tuple[int, str, Dict]:
try:
logger.info(f"获取文件信息: {url}")
response = session.get(
url,
headers={'Range': 'bytes=0-1'},
timeout=15,
stream=True,
verify=False
)
response.raise_for_status()
content_range = response.headers.get('Content-Range', '')
if content_range:
filesize = int(content_range.split('/')[-1])
else:
filesize = int(response.headers.get('Content-Length', 0))
if filesize == 0:
logger.warning("无法获取文件大小,将完整下载文件")
filename = None
if 'content-disposition' in response.headers:
content_disposition = response.headers['content-disposition']
if 'filename=' in content_disposition:
filename = content_disposition.split('filename=')[1].strip("\"'")
if not filename:
parsed = urlparse(url)
filename = os.path.basename(parsed.path) or 'download.bin'
filename = "".join(c for c in filename if c.isalnum() or c in ('.', '_', '-')).rstrip()
if not filename:
filename = 'download.bin'
accept_ranges = response.headers.get('Accept-Ranges', '').lower() == 'bytes'
response.close()
logger.info(f"获取文件信息成功: {filename}, 大小: {filesize} bytes, 支持断点续传: {accept_ranges}")
return filesize, filename, {
'accept_ranges': accept_ranges,
'content_type': response.headers.get('Content-Type', ''),
'last_modified': response.headers.get('Last-Modified', '')
}
except Exception as e:
logger.exception(f"获取文件信息失败: {str(e)}")
raise Exception(f"获取文件信息失败: {str(e)}")
class DownloadEngine:
def __init__(self, max_concurrent_tasks: int = 5, max_workers: int = 200):
self.tasks: Dict[str, DownloadTask] = {}
self.active_downloaders: Dict[str, List[ChunkDownloader]] = {}
self.progress_queue = queue.Queue()
self.completed_queue = queue.Queue()
self.task_lock = threading.Lock()
self.running = False
self.console = Console()
self.shutdown_flag = False
# 增大线程池,提高并发
self.executor = concurrent.futures.ThreadPoolExecutor(
max_workers=max_workers,
thread_name_prefix='DownloadWorker'
)
self.session_pool = {}
self.protocol_handlers = [HTTPHandler()]
self.speed_tracker = {}
self.last_update_time = {}
self.active_task_count = 0
self.live_active = False
# 清新配色主题
custom_theme = Theme({
"info": "#87CEEB", # 浅蓝
"success": "#98FB98", # 薄荷绿
"error": "#F08080", # 淡珊瑚
"warning": "#FFD700", # 淡金
"highlight": "#DDA0DD", # 淡紫
"bar.complete": "#98FB98",
"bar.finished": "#98FB98",
"bar.pulse": "#87CEEB",
"progress.percentage": "#DDA0DD",
})
self.console.push_theme(custom_theme)
# 圆角进度条,去除Spinner
self.progress_display = Progress(
TextColumn("[bold #87CEEB]{task.description}"),
BarColumn(
bar_width=None,
complete_style="#98FB98",
finished_style="#98FB98",
pulse_style="#87CEEB"
),
"[progress.percentage]{task.percentage:>3.1f}%",
"•",
DownloadColumn(),
"•",
TransferSpeedColumn(),
"•",
TimeRemainingColumn(),
console=self.console,
refresh_per_second=4
)
self.progress_tasks: Dict[str, TaskID] = {}
self.live = Live(self.progress_display, refresh_per_second=4, console=self.console, auto_refresh=True)
self.monitor_thread = threading.Thread(target=self._monitor_progress, daemon=True)
self.speed_calc_thread = threading.Thread(target=self._calculate_speeds, daemon=True)
def _create_session(self, task_id: str) -> requests.Session:
if task_id not in self.session_pool:
session = requests.Session()
# 优化重试策略
retry_strategy = Retry(
total=5,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504, 522, 524],
allowed_methods=["GET", "HEAD"],
raise_on_status=False
)
adapter = HTTPAdapter(
max_retries=retry_strategy,
pool_connections=20, # 增大连接池
pool_maxsize=100,
pool_block=False
)
session.mount("http://", adapter)
session.mount("https://", adapter)
# 优化请求头(支持压缩、长连接)
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Keep-Alive': 'timeout=30, max=1000',
})
self.session_pool[task_id] = session
return self.session_pool[task_id]
def _get_handler(self, url: str) -> ProtocolHandler:
for handler in self.protocol_handlers:
if handler.supports(url):
return handler
raise Exception(f"不支持的协议: {url}")
def add_task(self, url: str, output_dir: str = ".", **kwargs) -> str:
try:
handler = self._get_handler(url)
session = self._create_session("temp")
filesize, filename, metadata = handler.get_info(url, session)
session.close()
task_id = hashlib.md5(f"{url}_{datetime.now().timestamp()}".encode()).hexdigest()[:8]
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
filepath = output_path / filename
counter = 1
original_filepath = filepath
while filepath.exists():
stem = original_filepath.stem
suffix = original_filepath.suffix
filepath = output_path / f"{stem}_{counter}{suffix}"
counter += 1
# 动态调整分块大小(优化下载速度)
user_chunk_size = kwargs.get('chunk_size', 1024 * 1024)
if filesize > 1024 * 1024 * 1024: # >1GB
dynamic_chunk_size = 16 * 1024 * 1024
elif filesize > 100 * 1024 * 1024: # >100MB
dynamic_chunk_size = 4 * 1024 * 1024
else:
dynamic_chunk_size = user_chunk_size
# 创建任务,确保线程参数正确传递
task = DownloadTask(
url=url,
filepath=filepath,
filesize=filesize,
metadata=metadata,
threads=kwargs.get('threads', 8),
max_connections=kwargs.get('max_connections', 16),
chunk_size=dynamic_chunk_size,
timeout=kwargs.get('timeout', 30),
retry_count=kwargs.get('retry_count', 5),
speed_limit=kwargs.get('speed_limit', 0)
)
with self.task_lock:
self.tasks[task_id] = task
self.speed_tracker[task_id] = {'bytes': 0, 'time': time.time(), 'last_bytes': 0}
if filepath.exists() and filepath.stat().st_size > 0:
self._resume_task(task_id)
else:
self._prepare_file(task)
logger.info(f"任务添加成功: {task_id}, 保存到: {filepath}")
return task_id
except Exception as e:
logger.exception(f"添加任务失败: {str(e)}")
raise
def _prepare_file(self, task: DownloadTask):
if task.filesize <= 0:
return
try:
# 使用 truncate 快速预分配
with open(task.filepath, 'wb') as f:
f.truncate(task.filesize)
logger.info(f"预分配文件空间: {task.filepath}, 大小: {task.filesize}")
except Exception as e:
logger.warning(f"预分配文件空间失败,将使用动态写入: {str(e)}")
def _resume_task(self, task_id: str):
task = self.tasks[task_id]
if not task.filepath.exists():
return
downloaded = task.filepath.stat().st_size
task.downloaded = downloaded
logger.info(f"恢复下载: {task.filepath}, 已下载: {downloaded}/{task.filesize}")
if task.filesize > 0 and downloaded >= task.filesize:
task.status = DownloadStatus.COMPLETED
def start_task(self, task_id: str):
with self.task_lock:
if task_id not in self.tasks:
raise Exception("任务不存在")
task = self.tasks[task_id]
if task.status in [DownloadStatus.DOWNLOADING, DownloadStatus.COMPLETED]:
return
if task.status == DownloadStatus.ERROR:
logger.info(f"重新开始失败的任务: {task_id}")
task.status = DownloadStatus.DOWNLOADING
task.start_time = datetime.now()
task.last_update = datetime.now()
task.current_speed = 0.0
task.avg_speed = 0.0
task.eta = timedelta(seconds=0)
if task_id not in self.progress_tasks:
if task.filesize > 0:
self.progress_tasks[task_id] = self.progress_display.add_task(
description=f"{task.filepath.name[:30]}",
total=task.filesize,
completed=task.downloaded
)
else:
self.progress_tasks[task_id] = self.progress_display.add_task(
description=f"{task.filepath.name[:30]}",
total=0,
completed=task.downloaded,
start=False
)
if not self.live_active:
try:
self.live.start()
self.live_active = True
except Exception as e:
logger.error(f"启动Live显示失败: {str(e)}")
if not task.chunks and task.filesize > 0:
self._create_chunks(task)
self._start_downloaders(task_id)
self.active_task_count += 1
logger.info(f"开始下载任务: {task_id}, 活动任务数: {self.active_task_count}")
if not self.running:
self.running = True
if not self.monitor_thread.is_alive():
self.monitor_thread.start()
if not self.speed_calc_thread.is_alive():
self.speed_calc_thread.start()
def _create_chunks(self, task: DownloadTask):
if task.filesize <= 0:
return
# 使用 task.threads 作为分块数量
chunk_count = min(task.threads, max(1, task.filesize // task.chunk_size))
if chunk_count == 0:
chunk_count = 1
chunk_size = task.filesize // chunk_count
remainder = task.filesize % chunk_count
logger.info(f"创建分块: 大小={task.filesize}, 线程={task.threads}, 分块数={chunk_count}, 块大小={chunk_size}")
for i in range(chunk_count):
start = i * chunk_size + min(i, remainder)
end = start + chunk_size - 1
if i < remainder:
end += 1
if i == chunk_count - 1:
end = task.filesize - 1
task.chunks[i] = (start, end)
def _start_downloaders(self, task_id: str):
task = self.tasks[task_id]
if task_id not in self.active_downloaders:
self.active_downloaders[task_id] = []
session = self._create_session(task_id)
started_chunks = 0
for chunk_id, (start, end) in task.chunks.items():
if task.downloaded >= end + 1:
continue
chunk_start = max(start, task.downloaded)
if chunk_start > end:
continue
downloader = ChunkDownloader(task, chunk_id, chunk_start, end)
self.active_downloaders[task_id].append(downloader)
self.executor.submit(downloader.download, session, self.progress_queue)
started_chunks += 1
logger.info(f"启动 {started_chunks} 个分块下载器")
if started_chunks == 0 and task.filesize > 0:
task.status = DownloadStatus.COMPLETED
self.completed_queue.put((task_id, "completed"))
def _monitor_progress(self):
logger.info("进度监控线程启动")
while self.running and not self.shutdown_flag:
try:
item = self.progress_queue.get(timeout=1.0)
if not isinstance(item, tuple):
continue
if item[0] == 'error':
_, task_url, error_msg = item
task_id = None
for tid, t in self.tasks.items():
if t.url == task_url:
task_id = tid
break
if task_id:
logger.error(f"任务 {task_id} 出错: {error_msg}")
with self.task_lock:
if task_id in self.tasks:
task = self.tasks[task_id]
task.error_msg = error_msg
task.status = DownloadStatus.ERROR
if task_id in self.progress_tasks:
self.progress_display.update(self.progress_tasks[task_id], visible=False)
self.completed_queue.put((task_id, "error"))
else:
task_url, chunk_id, downloaded_bytes = item
task_id = None
task = None
with self.task_lock:
for tid, t in self.tasks.items():
if t.url == task_url:
task_id = tid
task = t
break
if task_id and task and task.status == DownloadStatus.DOWNLOADING:
total_downloaded = 0
for cid, downloader in enumerate(self.active_downloaders.get(task_id, [])):
if cid == chunk_id:
total_downloaded += downloaded_bytes
else:
total_downloaded += downloader.downloaded
for cid, (start, end) in task.chunks.items():
if cid not in [d.chunk_id for d in self.active_downloaders.get(task_id, [])]:
total_downloaded += (end - start + 1)
old_downloaded = task.downloaded
task.downloaded = min(total_downloaded, task.filesize)
if task.downloaded > old_downloaded:
if task_id in self.speed_tracker:
self.speed_tracker[task_id]['bytes'] += (task.downloaded - old_downloaded)
if task_id in self.progress_tasks:
if task.filesize > 0:
self.progress_display.update(
self.progress_tasks[task_id],
completed=task.downloaded,
visible=True
)
else:
self.progress_display.update(
self.progress_tasks[task_id],
total=max(task.downloaded * 2, 1024 * 1024),
completed=task.downloaded,
visible=True
)
task.last_update = datetime.now()
if task.filesize > 0 and task.downloaded >= task.filesize:
task.status = DownloadStatus.COMPLETED
logger.info(f"任务 {task_id} 下载完成")
if task_id in self.progress_tasks:
self.progress_display.update(self.progress_tasks[task_id], visible=False)
self.completed_queue.put((task_id, "completed"))
self._on_task_complete(task_id)
except queue.Empty:
continue
except Exception as e:
logger.exception(f"监控进度时出错: {str(e)}")
logger.info("进度监控线程退出")
def _calculate_speeds(self):
logger.info("速度计算线程启动")
while self.running and not self.shutdown_flag:
time.sleep(1.0)
try:
current_time = time.time()
with self.task_lock:
for task_id, task in self.tasks.items():
if task.status != DownloadStatus.DOWNLOADING:
continue
if task_id in self.speed_tracker:
tracker = self.speed_tracker[task_id]
time_diff = current_time - tracker['time']
if time_diff > 0.5:
bytes_diff = tracker['bytes']
speed = bytes_diff / time_diff / 1024
task.current_speed = speed
if task.avg_speed == 0:
task.avg_speed = speed
else:
task.avg_speed = task.avg_speed * 0.8 + speed * 0.2
task.update_eta()
if task_id in self.progress_tasks:
self.progress_display.update(
self.progress_tasks[task_id],
speed=task.avg_speed * 1024
)
tracker['bytes'] = 0
tracker['time'] = current_time
except Exception as e:
logger.exception(f"计算速度时出错: {str(e)}")
logger.info("速度计算线程退出")
def _on_task_complete(self, task_id: str):
with self.task_lock:
if task_id in self.tasks:
task = self.tasks[task_id]
if task.filesize > 0 and task.filepath.exists():
actual_size = task.filepath.stat().st_size
if actual_size != task.filesize:
logger.warning(f"文件大小不匹配: 期望 {task.filesize}, 实际 {actual_size}")
if actual_size < task.filesize:
task.status = DownloadStatus.ERROR
task.error_msg = f"文件不完整: 期望 {task.filesize} 字节, 实际 {actual_size} 字节"
return
if task_id in self.active_downloaders:
for downloader in self.active_downloaders[task_id]:
downloader.pause()
del self.active_downloaders[task_id]
self.active_task_count = max(0, self.active_task_count - 1)
if self.active_task_count == 0 and self.live_active:
try:
self.live.stop()
self.live_active = False
except Exception as e:
logger.error(f"停止Live显示失败: {str(e)}")
def pause_task(self, task_id: str):
with self.task_lock:
if task_id not in self.tasks:
return
task = self.tasks[task_id]
if task.status != DownloadStatus.DOWNLOADING:
return
task.status = DownloadStatus.PAUSED
if task_id in self.active_downloaders:
for downloader in self.active_downloaders[task_id]:
downloader.pause()
if task_id in self.progress_tasks:
self.progress_display.update(self.progress_tasks[task_id], visible=False)
self.active_task_count = max(0, self.active_task_count - 1)
if self.active_task_count == 0 and self.live_active:
try:
self.live.stop()
self.live_active = False
except Exception as e:
logger.error(f"停止Live显示失败: {str(e)}")
def resume_task(self, task_id: str):
with self.task_lock:
if task_id not in self.tasks:
return
task = self.tasks[task_id]
if task.status != DownloadStatus.PAUSED:
return
if task_id in self.progress_tasks:
self.progress_display.update(self.progress_tasks[task_id], visible=True)
self._start_downloaders(task_id)
task.status = DownloadStatus.DOWNLOADING
task.last_update = datetime.now()
self.active_task_count += 1
if not self.live_active:
try:
self.live.start()
self.live_active = True
except Exception as e:
logger.error(f"启动Live显示失败: {str(e)}")
if not self.running:
self.running = True
if not self.monitor_thread.is_alive():
self.monitor_thread = threading.Thread(target=self._monitor_progress, daemon=True)
self.monitor_thread.start()
if not self.speed_calc_thread.is_alive():
self.speed_calc_thread = threading.Thread(target=self._calculate_speeds, daemon=True)
self.speed_calc_thread.start()
def cancel_task(self, task_id: str):
with self.task_lock:
if task_id not in self.tasks:
return
task = self.tasks[task_id]
if task.status in [DownloadStatus.COMPLETED, DownloadStatus.CANCELED]:
return
old_status = task.status
task.status = DownloadStatus.CANCELED
if task_id in self.active_downloaders:
for downloader in self.active_downloaders[task_id]:
downloader.pause()
if task_id in self.progress_tasks:
self.progress_display.update(self.progress_tasks[task_id], visible=False)
self.completed_queue.put((task_id, "canceled"))
if old_status == DownloadStatus.DOWNLOADING:
self.active_task_count = max(0, self.active_task_count - 1)
if self.active_task_count == 0 and self.live_active:
try:
self.live.stop()
self.live_active = False
except Exception as e:
logger.error(f"停止Live显示失败: {str(e)}")
def remove_task(self, task_id: str, delete_file: bool = False):
self.cancel_task(task_id)
with self.task_lock:
if task_id not in self.tasks:
return
task = self.tasks[task_id]
if delete_file and task.filepath.exists():
try:
os.remove(task.filepath)
except Exception as e:
logger.error(f"删除文件失败: {str(e)}")
if task_id in self.session_pool:
try:
self.session_pool[task_id].close()
except:
pass
del self.session_pool[task_id]
if task_id in self.progress_tasks:
try:
self.progress_display.remove_task(self.progress_tasks[task_id])
except:
pass
del self.progress_tasks[task_id]
del self.tasks[task_id]
if task_id in self.active_downloaders:
del self.active_downloaders[task_id]
if task_id in self.speed_tracker:
del self.speed_tracker[task_id]
def get_task_info(self, task_id: str) -> Optional[DownloadTask]:
with self.task_lock:
return self.tasks.get(task_id)
def list_tasks(self) -> List[Tuple[str, DownloadTask]]:
with self.task_lock:
return list(self.tasks.items())
def shutdown(self):
logger.info("开始关闭下载引擎")
self.shutdown_flag = True
self.running = False
with self.task_lock:
task_ids = list(self.tasks.keys())
for task_id in task_ids:
try:
self.pause_task(task_id)
except Exception as e:
logger.error(f"暂停任务 {task_id} 时出错: {str(e)}")
self.executor.shutdown(wait=False)
for session_id, session in self.session_pool.items():
try:
session.close()
except:
pass
self.session_pool.clear()
if self.live_active:
try:
self.live.stop()
self.live_active = False
except Exception as e:
logger.error(f"停止Live显示失败: {str(e)}")
logger.info("下载引擎已关闭")
# ========== 现代化CLI实现(配色同步更新)==========
class TaskIdCompleter(Completer):
def __init__(self, get_task_ids: Callable[[], List[str]]):
self.get_task_ids = get_task_ids
def get_completions(self, document, complete_event):
text = document.text_before_cursor
words = text.split()
if len(words) >= 2:
last_word = words[-1]
for tid in self.get_task_ids():
if tid.startswith(last_word):
yield Completion(tid, start_position=-len(last_word))
class ModernCLI:
def __init__(self):
self.engine = DownloadEngine(max_workers=200)
# 清新配色主题
cli_theme = Theme({
"info": "#87CEEB",
"success": "#98FB98",
"error": "#F08080",
"warning": "#FFD700",
"highlight": "#DDA0DD",
})
self.console = Console(theme=cli_theme)
self.running = True
self.history_file = Path.home() / ".air2_history"
self.commands: Dict[str, Callable] = {
'help': self.show_help, 'add': self.add_task, 'start': self.start_task,
'pause': self.pause_task, 'resume': self.resume_task, 'cancel': self.cancel_task,
'remove': self.remove_task, 'list': self.list_tasks, 'ls': self.list_tasks,
'status': self.show_status, 'exit': self.exit, 'quit': self.exit,
'clear': self.clear, 'history': self.show_history, 'unzip': self.unzip_file,
}
self.base_commands = list(self.commands.keys())
if PROMPT_TOOLKIT_AVAILABLE:
self._setup_prompt_toolkit()
else:
self._setup_fallback()
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
def _setup_prompt_toolkit(self):
self.pt_style = PtStyle.from_dict({
'prompt': 'bold #87CEEB',
'command': 'bold #98FB98',
'error': 'bold #F08080',
})
bindings = KeyBindings()
@bindings.add('c-c')
def _(event):
event.app.exit()
command_completer = WordCompleter(self.base_commands, ignore_case=True)
def get_task_ids():
return [tid for tid, _ in self.engine.list_tasks()]
task_completer = TaskIdCompleter(get_task_ids)
class DynamicCompleter(Completer):
def get_completions(self, document, complete_event):
text = document.text_before_cursor
words = text.split()
if len(words) == 0:
yield from command_completer.get_completions(document, complete_event)
elif len(words) == 1:
if not text.endswith(' '):
yield from command_completer.get_completions(document, complete_event)
else:
cmd = words[0]
if cmd in ['start', 'pause', 'resume', 'cancel', 'remove', 'unzip']:
yield from task_completer.get_completions(document, complete_event)
else:
cmd = words[0]
if cmd in ['start', 'pause', 'resume', 'cancel', 'remove', 'unzip']:
yield from task_completer.get_completions(document, complete_event)
self.session = PromptSession(
history=FileHistory(str(self.history_file)),
auto_suggest=AutoSuggestFromHistory(),
completer=DynamicCompleter(),
style=self.pt_style,
key_bindings=bindings,
complete_while_typing=True,
)
self.use_prompt_toolkit = True
def _setup_fallback(self):
self.use_prompt_toolkit = False
self.console.print("[warning]安装 prompt_toolkit 可获得命令补全和历史记录[/warning]")
def _signal_handler(self, signum, frame):
signal_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM"
self.console.print(f"\n[warning]收到 {signal_name} 信号,正在关闭下载器...[/warning]")
self.running = False
try:
self.engine.shutdown()
except Exception as e:
logger.exception("关闭引擎时出错")
sys.exit(0)
def _format_size(self, size: int) -> str:
if size == 0:
return "0 B"
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size < 1024.0:
return f"{size:.2f} {unit}"
size /= 1024.0
return f"{size:.2f} PB"
def _format_speed(self, speed: float) -> str:
if speed <= 0:
return "-"
elif speed < 1024:
return f"{speed:.1f} KB/s"
elif speed < 1024 * 1024:
return f"{speed / 1024:.1f} MB/s"
else:
return f"{speed / (1024 * 1024):.1f} GB/s"
def _format_time(self, td: timedelta) -> str:
total_seconds = int(td.total_seconds())
if total_seconds <= 0:
return "-"
if total_seconds < 60:
return f"{total_seconds}秒"
elif total_seconds < 3600:
return f"{total_seconds // 60}分钟"
elif total_seconds < 86400:
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
return f"{hours}小时{minutes}分"
else:
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
return f"{days}天{hours}小时"
def show_help(self, args=None):
help_text = """
[bold #87CEEB]Air2 现代化命令行帮助[/bold #87CEEB]
[bold]基本命令:[/bold]
[green]add <URL> [选项][/green] 添加下载任务
[green]start <任务ID>[/green] 开始下载
[green]pause <任务ID>[/green] 暂停任务
[green]resume <任务ID>[/green] 恢复任务
[green]cancel <任务ID>[/green] 取消任务
[green]remove <任务ID> [--delete][/green] 移除任务(可选删除文件)
[green]unzip <任务ID> [输出目录][/green] 解压已下载的 ZIP 文件
[green]list / ls[/green] 列出所有任务
[green]status[/green] 显示系统状态
[green]clear[/green] 清屏
[green]history[/green] 显示命令历史
[green]exit / quit[/green] 退出程序
[bold]添加任务选项:[/bold]
[yellow]--threads=<数量>[/yellow] 下载线程数 (默认: 8)
[yellow]--output=<目录>[/yellow] 保存目录 (默认: 当前目录)
[yellow]--chunk-size=<大小>[/yellow] 分块大小,支持 K/M/G (默认: 动态)
[yellow]--speed-limit=<KB/s>[/yellow] 下载限速 (0=不限速)
[bold]快捷键:[/bold]
[bold]Tab[/bold] 自动补全命令/任务ID
[bold]↑/↓[/bold] 浏览历史命令
[bold]Ctrl+C[/bold] 中止当前输入/退出程序
[bold]示例:[/bold]
add https://example.com/file.zip --threads=16 --output=/downloads
add https://example.com/large.iso --chunk-size=10M --speed-limit=1024
start a1b2c3d4
pause a1b2c3d4
remove a1b2c3d4 --delete
unzip a1b2c3d4 /path/to/extract
"""
self.console.print(Panel(help_text, title="帮助", border_style="#87CEEB", padding=(1, 2)))
def add_task(self, args):
if not args:
self.console.print("[error]错误: 请提供下载URL[/error]")
return
url = args[0]
kwargs = {'threads': 8, 'output': '.', 'chunk_size': 1024 * 1024, 'speed_limit': 0}
for arg in args[1:]:
if arg.startswith('--'):
if '=' in arg:
key, value = arg[2:].split('=', 1)
key = key.replace('-', '_')
if key == 'chunk_size':
value = value.upper()
if value.endswith('K'):
value = int(float(value[:-1]) * 1024)
elif value.endswith('M'):
value = int(float(value[:-1]) * 1024 * 1024)
elif value.endswith('G'):
value = int(float(value[:-1]) * 1024 * 1024 * 1024)
else:
value = int(value)
elif key in ['threads', 'speed_limit', 'timeout', 'retry_count']:
value = int(value)
kwargs[key] = value
try:
output_dir = kwargs.pop('output', '.')
task_id = self.engine.add_task(url, output_dir, **kwargs)
self.console.print(f"[success]任务添加成功! 任务ID: {task_id}[/success]")
self.console.print(f" 使用 [yellow]start {task_id}[/yellow] 开始下载")
except Exception as e:
self.console.print(f"[error]添加任务失败: {str(e)}[/error]")
def start_task(self, args):
if not args:
self.console.print("[error]错误: 请提供任务ID[/error]")
return
task_id = args[0]
try:
self.engine.start_task(task_id)
except Exception as e:
self.console.print(f"[error]启动任务失败: {str(e)}[/error]")
def pause_task(self, args):
if not args:
self.console.print("[error]错误: 请提供任务ID[/error]")
return
task_id = args[0]
try:
self.engine.pause_task(task_id)
self.console.print(f"[warning]任务 {task_id} 已暂停[/warning]")
except Exception as e:
self.console.print(f"[error]暂停任务失败: {str(e)}[/error]")
def resume_task(self, args):
if not args:
self.console.print("[error]错误: 请提供任务ID[/error]")
return
task_id = args[0]
try:
self.engine.resume_task(task_id)
self.console.print(f"[success]任务 {task_id} 已恢复[/success]")
except Exception as e:
self.console.print(f"[error]恢复任务失败: {str(e)}[/error]")
def cancel_task(self, args):
if not args:
self.console.print("[error]错误: 请提供任务ID[/error]")
return
task_id = args[0]
try:
self.engine.cancel_task(task_id)
self.console.print(f"[warning]任务 {task_id} 已取消[/warning]")
except Exception as e:
self.console.print(f"[error]取消任务失败: {str(e)}[/error]")
def remove_task(self, args):
if not args:
self.console.print("[error]错误: 请提供任务ID[/error]")
return
task_id = args[0]
delete_file = '--delete' in args
try:
self.engine.remove_task(task_id, delete_file)
self.console.print(f"[success]任务 {task_id} 已移除" + (" (文件已删除)" if delete_file else "") + "[/success]")
except Exception as e:
self.console.print(f"[error]移除任务失败: {str(e)}[/error]")
def unzip_file(self, args):
"""解压已下载的 ZIP 文件"""
if not args:
self.console.print("[error]错误: 请提供任务ID或ZIP文件路径[/error]")
return
target = args[0]
output_dir = args[1] if len(args) > 1 else None
# 判断是任务ID还是直接的文件路径
zip_path = None
task = None
if target in [tid for tid, _ in self.engine.list_tasks()]:
task = self.engine.get_task_info(target)
if not task:
self.console.print(f"[error]任务 {target} 不存在[/error]")
return
if task.status != DownloadStatus.COMPLETED:
self.console.print(f"[error]任务 {target} 尚未完成,无法解压[/error]")
return
zip_path = task.filepath
else:
# 当作文件路径处理
zip_path = Path(target)
if not zip_path.exists():
self.console.print(f"[error]文件不存在: {target}[/error]")
return
if not zipfile.is_zipfile(zip_path):
self.console.print(f"[error]不是有效的 ZIP 文件: {zip_path}[/error]")
return
# 确定输出目录
if output_dir:
extract_to = Path(output_dir)
else:
# 默认解压到与 ZIP 同名的文件夹(去掉 .zip 后缀)
extract_to = zip_path.parent / zip_path.stem
try:
extract_to.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path, 'r') as zf:
# 显示文件列表并解压
file_list = zf.namelist()
self.console.print(f"[info]正在解压 {len(file_list)} 个文件到 {extract_to} ...[/info]")
zf.extractall(extract_to)
self.console.print(f"[success]解压完成! 文件已保存至: {extract_to}[/success]")
except Exception as e:
self.console.print(f"[error]解压失败: {str(e)}[/error]")
def list_tasks(self, args=None):
tasks = self.engine.list_tasks()
if not tasks:
self.console.print("[warning]没有任务[/warning]")
return
table = Table(title="下载任务", show_lines=True, expand=True, border_style="#87CEEB")
table.add_column("ID", style="#87CEEB", no_wrap=True, width=8)
table.add_column("文件名", style="white", width=30)
table.add_column("大小", style="#98FB98", width=12)
table.add_column("进度", justify="right", width=15)
table.add_column("速度", justify="right", width=12)
table.add_column("ETA", justify="right", width=12)
table.add_column("状态", style="bold", width=10)
table.add_column("选项", justify="left", width=15)
for task_id, task in tasks:
size_str = self._format_size(task.filesize) if task.filesize > 0 else "未知"
progress_str = f"{task.progress:.1f}%" if task.filesize > 0 else "流式"
speed_str = self._format_speed(task.avg_speed) if task.avg_speed > 0 else "-"
eta_str = self._format_time(task.eta) if task.eta.total_seconds() > 0 else "-"
if task.status == DownloadStatus.COMPLETED:
eta_str = "已完成"
elif task.status in [DownloadStatus.PAUSED, DownloadStatus.CANCELED, DownloadStatus.ERROR]:
eta_str = "-"
status_color = {
DownloadStatus.WAITING: "dim",
DownloadStatus.DOWNLOADING: "#98FB98",
DownloadStatus.PAUSED: "#FFD700",
DownloadStatus.COMPLETED: "#87CEEB",
DownloadStatus.ERROR: "#F08080",
DownloadStatus.CANCELED: "dark_grey"
}.get(task.status, "white")
status_str = f"[{status_color}]{task.status.value}[/{status_color}]"
options_str = f"线程:{task.threads}"
if task.speed_limit > 0:
options_str += f", 限速:{task.speed_limit}KB/s"
table.add_row(task_id, str(task.filepath.name)[:30], size_str, progress_str, speed_str, eta_str, status_str, options_str)
self.console.print(table)
def show_status(self, args=None):
tasks = self.engine.list_tasks()
downloading = sum(1 for _, t in tasks if t.status == DownloadStatus.DOWNLOADING)
paused = sum(1 for _, t in tasks if t.status == DownloadStatus.PAUSED)
completed = sum(1 for _, t in tasks if t.status == DownloadStatus.COMPLETED)
error = sum(1 for _, t in tasks if t.status == DownloadStatus.ERROR)
canceled = sum(1 for _, t in tasks if t.status == DownloadStatus.CANCELED)
total_size = sum(t.filesize for _, t in tasks)
total_downloaded = sum(t.downloaded for _, t in tasks)
total_progress = (total_downloaded / total_size * 100) if total_size > 0 else 0
status_text = f"""
[bold #87CEEB]系统状态:[/bold #87CEEB]
[bold]任务统计:[/bold]
总计: {len(tasks)} 个任务
下载中: [#98FB98]{downloading}[/#98FB98]
已暂停: [#FFD700]{paused}[/#FFD700]
已完成: [#87CEEB]{completed}[/#87CEEB]
错误: [#F08080]{error}[/#F08080]
已取消: [dark_grey]{canceled}[/dark_grey]
[bold]数据统计:[/bold]
总大小: {self._format_size(total_size) if total_size > 0 else "未知"}
已下载: {self._format_size(total_downloaded)}
总进度: {total_progress:.1f}% if total_size > 0 else "计算中"
[bold]性能信息:[/bold]
活动任务: {self.engine.active_task_count}
最大线程: 200
工作线程: {threading.active_count()}
"""
self.console.print(Panel(status_text, title="系统状态", border_style="#87CEEB", padding=(1, 2)))
def show_history(self, args=None):
if self.use_prompt_toolkit and hasattr(self.session, 'history'):
history_entries = list(self.session.history.get_strings())
if history_entries:
self.console.print("[bold]最近命令历史:[/bold]")
for i, entry in enumerate(history_entries[-20:], 1):
self.console.print(f" {i:3d}. {entry}")
else:
self.console.print("[warning]暂无命令历史[/warning]")
else:
self.console.print("[warning]命令历史功能需要 prompt_toolkit[/warning]")
def clear(self, args=None):
self.console.clear()
def exit(self, args=None):
confirm = Confirm.ask("确定要退出吗? 活动下载将会暂停")
if confirm:
self.console.print("[warning]正在关闭下载引擎...[/warning]")
try:
self.engine.shutdown()
except Exception as e:
logger.exception("关闭引擎时出错")
self.running = False
self.console.print("[success]再见![/success]")
sys.exit(0)
def process_completed_tasks(self):
while not self.engine.completed_queue.empty():
try:
task_id, status = self.engine.completed_queue.get_nowait()
task = self.engine.get_task_info(task_id)
if task:
if status == "completed":
self.console.print(f"\n[success]任务 {task_id} 完成! 文件: {task.filepath}[/success]")
elif status == "error":
self.console.print(f"\n[error]任务 {task_id} 失败: {task.error_msg}[/error]")
elif status == "canceled":
self.console.print(f"\n[warning]任务 {task_id} 已取消[/warning]")
except queue.Empty:
break
def run(self):
self.console.print(Panel.fit("[bold #87CEEB]Air2[/bold #87CEEB] - 高性能下载引擎", subtitle="输入 'help' 查看帮助", border_style="#87CEEB"))
if not PROMPT_TOOLKIT_AVAILABLE:
self.console.print("[warning]安装 prompt_toolkit 可获得更好的交互体验[/warning]")
self.console.print("pip install prompt_toolkit\n")
while self.running:
try:
self.process_completed_tasks()
if self.use_prompt_toolkit:
try:
user_input = self.session.prompt(">>> ")
except KeyboardInterrupt:
continue
except EOFError:
self.exit()
break
else:
try:
user_input = input("\n>>> ").strip()
except EOFError:
self.exit()
break
except KeyboardInterrupt:
self.console.print("\n[warning]输入 'exit' 退出程序[/warning]")
continue
if not user_input:
continue
parts = user_input.strip().split()
command = parts[0].lower()
args = parts[1:] if len(parts) > 1 else []
if command in self.commands:
self.commands[command](args)
else:
self.console.print(f"[error]未知命令: {command}[/error]")
self.console.print("输入 'help' 查看可用命令")
except KeyboardInterrupt:
self.console.print("\n[warning]输入 'exit' 退出程序[/warning]")
except Exception as e:
self.console.print(f"[error]发生错误: {str(e)}[/error]")
logger.exception("主循环错误")
try:
self.engine.shutdown()
except:
pass
def execute_command_line():
"""命令行直接执行模式,支持 air2 add URL 等用法"""
parser = argparse.ArgumentParser(description="Air2 - 高性能下载引擎", add_help=False)
parser.add_argument("command", nargs="?", help="命令: add, start, pause, resume, cancel, remove, list, status, unzip")
parser.add_argument("args", nargs="*", help="命令参数")
parser.add_argument("--help", action="store_true", help="显示帮助")
# 为了兼容原有风格,我们手动解析简单的命令
if len(sys.argv) == 1:
# 无参数,进入交互模式
return None
# 处理 help
if sys.argv[1] in ["help", "--help", "-h"]:
print("Air2 命令行用法:")
print(" air2 add <URL> [--threads=N] [--output=DIR] [--chunk-size=SIZE] [--speed-limit=KB]")
print(" air2 start <任务ID>")
print(" air2 pause <任务ID>")
print(" air2 resume <任务ID>")
print(" air2 cancel <任务ID>")
print(" air2 remove <任务ID> [--delete]")
print(" air2 unzip <任务ID或ZIP文件> [输出目录]")
print(" air2 list")
print(" air2 status")
print(" air2 help")
sys.exit(0)
command = sys.argv[1].lower()
args = sys.argv[2:]
# 创建引擎
engine = DownloadEngine(max_workers=200)
console = Console()
# 辅助函数
def format_size(size):
if size == 0: return "0 B"
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size < 1024.0:
return f"{size:.2f} {unit}"
size /= 1024.0
return f"{size:.2f} PB"
def format_speed(speed):
if speed <= 0: return "-"
elif speed < 1024: return f"{speed:.1f} KB/s"
elif speed < 1024*1024: return f"{speed/1024:.1f} MB/s"
else: return f"{speed/(1024*1024):.1f} GB/s"
def wait_for_task(task_id, console):
"""等待任务完成(用于命令行模式)"""
from rich.live import Live
from rich.progress import Progress, BarColumn, DownloadColumn, TransferSpeedColumn, TimeRemainingColumn, TextColumn
progress = Progress(
TextColumn("[bold #87CEEB]{task.description}"),
BarColumn(complete_style="#98FB98", finished_style="#98FB98"),
"[progress.percentage]{task.percentage:>3.1f}%",
"•", DownloadColumn(), "•", TransferSpeedColumn(), "•", TimeRemainingColumn(),
console=console, refresh_per_second=4
)
live = Live(progress, console=console, auto_refresh=True)
task_obj = engine.get_task_info(task_id)
if not task_obj:
console.print(f"[error]任务 {task_id} 不存在")
return False
if task_obj.filesize > 0:
task_id_progress = progress.add_task(f"{task_obj.filepath.name[:30]}", total=task_obj.filesize, completed=task_obj.downloaded)
else:
task_id_progress = progress.add_task(f"{task_obj.filepath.name[:30]}", total=0, completed=task_obj.downloaded)
live.start()
try:
while True:
task = engine.get_task_info(task_id)
if not task:
console.print(f"[error]任务 {task_id} 已消失")
live.stop()
return False
if task.status == DownloadStatus.COMPLETED:
progress.update(task_id_progress, completed=task.filesize)
live.stop()
console.print(f"[success]下载完成: {task.filepath}[/success]")
return True
elif task.status == DownloadStatus.ERROR:
live.stop()
console.print(f"[error]下载失败: {task.error_msg}[/error]")
return False
elif task.status == DownloadStatus.CANCELED:
live.stop()
console.print(f"[warning]下载已取消[/warning]")
return False
else:
if task.filesize > 0:
progress.update(task_id_progress, completed=task.downloaded, total=task.filesize)
else:
progress.update(task_id_progress, completed=task.downloaded, total=max(task.downloaded*2, 1024*1024))
# 更新速度
if task.avg_speed > 0:
progress.update(task_id_progress, speed=task.avg_speed*1024)
time.sleep(0.5)
except KeyboardInterrupt:
live.stop()
console.print("\n[warning]用户中断,正在取消任务...[/warning]")
engine.cancel_task(task_id)
return False
finally:
live.stop()
# 执行命令
try:
if command == "add":
if not args:
console.print("[error]请提供 URL")
sys.exit(1)
url = args[0]
kwargs = {'threads': 8, 'output': '.', 'chunk_size': 1024*1024, 'speed_limit': 0}
# 解析选项
for arg in args[1:]:
if arg.startswith('--'):
if '=' in arg:
key, value = arg[2:].split('=', 1)
key = key.replace('-', '_')
if key == 'chunk_size':
value = value.upper()
if value.endswith('K'): value = int(float(value[:-1]) * 1024)
elif value.endswith('M'): value = int(float(value[:-1]) * 1024*1024)
elif value.endswith('G'): value = int(float(value[:-1]) * 1024*1024*1024)
else: value = int(value)
elif key in ['threads', 'speed_limit']:
value = int(value)
kwargs[key] = value
output_dir = kwargs.pop('output', '.')
task_id = engine.add_task(url, output_dir, **kwargs)
console.print(f"[success]任务添加成功: {task_id}[/success]")
# 可选:自动开始下载(默认自动开始)
console.print(f"开始下载 {task_id} ...")
engine.start_task(task_id)
# 等待下载完成
success = wait_for_task(task_id, console)
sys.exit(0 if success else 1)
elif command == "start":
if not args:
console.print("[error]请提供任务ID")
sys.exit(1)
task_id = args[0]
engine.start_task(task_id)
console.print(f"等待任务 {task_id} 完成...")
success = wait_for_task(task_id, console)
sys.exit(0 if success else 1)
elif command == "pause":
if not args:
console.print("[error]请提供任务ID")
sys.exit(1)
task_id = args[0]
engine.pause_task(task_id)
console.print(f"[warning]任务 {task_id} 已暂停[/warning]")
sys.exit(0)
elif command == "resume":
if not args:
console.print("[error]请提供任务ID")
sys.exit(1)
task_id = args[0]
engine.resume_task(task_id)
console.print(f"[success]任务 {task_id} 已恢复,等待完成...[/success]")
success = wait_for_task(task_id, console)
sys.exit(0 if success else 1)
elif command == "cancel":
if not args:
console.print("[error]请提供任务ID")
sys.exit(1)
task_id = args[0]
engine.cancel_task(task_id)
console.print(f"[warning]任务 {task_id} 已取消[/warning]")
sys.exit(0)
elif command == "remove":
if not args:
console.print("[error]请提供任务ID")
sys.exit(1)
task_id = args[0]
delete_file = '--delete' in args
engine.remove_task(task_id, delete_file)
console.print(f"[success]任务 {task_id} 已移除" + (" (文件已删除)" if delete_file else ""))
sys.exit(0)
elif command == "unzip":
if not args:
console.print("[error]请提供任务ID或ZIP文件路径")
sys.exit(1)
target = args[0]
output_dir = args[1] if len(args) > 1 else None
zip_path = None
task = None
# 判断是否为任务ID
if target in [tid for tid, _ in engine.list_tasks()]:
task = engine.get_task_info(target)
if not task:
console.print(f"[error]任务 {target} 不存在")
sys.exit(1)
if task.status != DownloadStatus.COMPLETED:
console.print(f"[error]任务 {target} 尚未完成,无法解压")
sys.exit(1)
zip_path = task.filepath
else:
zip_path = Path(target)
if not zip_path.exists():
console.print(f"[error]文件不存在: {target}")
sys.exit(1)
if not zipfile.is_zipfile(zip_path):
console.print(f"[error]不是有效的 ZIP 文件: {zip_path}")
sys.exit(1)
if output_dir:
extract_to = Path(output_dir)
else:
extract_to = zip_path.parent / zip_path.stem
try:
extract_to.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path, 'r') as zf:
console.print(f"正在解压 {len(zf.namelist())} 个文件到 {extract_to} ...")
zf.extractall(extract_to)
console.print(f"[success]解压完成! 保存至: {extract_to}")
sys.exit(0)
except Exception as e:
console.print(f"[error]解压失败: {str(e)}")
sys.exit(1)
elif command == "list" or command == "ls":
tasks = engine.list_tasks()
if not tasks:
console.print("[warning]没有任务")
else:
table = Table(title="下载任务", show_lines=True, border_style="#87CEEB")
table.add_column("ID", style="#87CEEB", no_wrap=True)
table.add_column("文件名", width=30)
table.add_column("大小", style="#98FB98")
table.add_column("进度")
table.add_column("状态")
for tid, t in tasks:
size_str = format_size(t.filesize) if t.filesize > 0 else "未知"
prog_str = f"{t.progress:.1f}%" if t.filesize > 0 else "流式"
status_color = {
DownloadStatus.WAITING: "dim", DownloadStatus.DOWNLOADING: "#98FB98",
DownloadStatus.PAUSED: "#FFD700", DownloadStatus.COMPLETED: "#87CEEB",
DownloadStatus.ERROR: "#F08080", DownloadStatus.CANCELED: "dark_grey"
}.get(t.status, "white")
status_str = f"[{status_color}]{t.status.value}[/{status_color}]"
table.add_row(tid, str(t.filepath.name)[:30], size_str, prog_str, status_str)
console.print(table)
sys.exit(0)
elif command == "status":
tasks = engine.list_tasks()
downloading = sum(1 for _, t in tasks if t.status == DownloadStatus.DOWNLOADING)
paused = sum(1 for _, t in tasks if t.status == DownloadStatus.PAUSED)
completed = sum(1 for _, t in tasks if t.status == DownloadStatus.COMPLETED)
error = sum(1 for _, t in tasks if t.status == DownloadStatus.ERROR)
canceled = sum(1 for _, t in tasks if t.status == DownloadStatus.CANCELED)
total_size = sum(t.filesize for _, t in tasks)
total_downloaded = sum(t.downloaded for _, t in tasks)
console.print(f"任务总数: {len(tasks)} | 下载中: {downloading} | 暂停: {paused} | 完成: {completed} | 错误: {error} | 取消: {canceled}")
if total_size > 0:
console.print(f"总大小: {format_size(total_size)} | 已下载: {format_size(total_downloaded)} | 进度: {total_downloaded/total_size*100:.1f}%")
else:
console.print("总大小: 未知")
sys.exit(0)
else:
console.print(f"[error]未知命令: {command}")
console.print("输入 air2 help 查看帮助")
sys.exit(1)
except Exception as e:
console.print(f"[error]执行命令时出错: {str(e)}")
logger.exception("命令行模式错误")
sys.exit(1)
finally:
engine.shutdown()
def main():
# 判断是否进入命令行模式
if len(sys.argv) > 1 and sys.argv[1] not in ['help', '--help', '-h']:
execute_command_line()
else:
# 交互模式
try:
cli = ModernCLI()
cli.run()
except Exception as e:
console = Console()
console.print(f"[error]程序启动失败: {str(e)}[/error]")
logger.exception("程序启动失败")
sys.exit(1)
if __name__ == "__main__":
main()