1666 lines
66 KiB
Python
1666 lines
66 KiB
Python
#!/usr/bin/env python3
|
|
"""transfer.py - 统一文件传输工具(rsync/rclone/pv+tar 后端,CLI/TUI 双模式)
|
|
|
|
功能:
|
|
- 多后端支持: rsync / rclone / pv+tar
|
|
- CLI/TUI 双模式, TUI 底部可交互输入栏
|
|
- 实时日志写入本地目录, 滚动保留最近 N 个 (默认 20)
|
|
- 进度/性能可视化 (总进度、单文件、速度曲线)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import shlex
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import tempfile
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from glob import glob
|
|
import hashlib
|
|
|
|
# ---------------------------------------------------------------- 工具函数
|
|
|
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\r")
|
|
_SPEED_RE = re.compile(r"([\d.]+)\s*([KMGTP]?)i?B?/s", re.IGNORECASE)
|
|
_UNIT_MULT = {"": 1, "K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4, "P": 1024**5}
|
|
|
|
|
|
def clean_ansi(text: str) -> str:
|
|
"""移除 ANSI 转义序列与回车符"""
|
|
return _ANSI_RE.sub("", text)
|
|
|
|
|
|
def parse_speed(text: str) -> float:
|
|
"""解析速度字符串如 '356.77MB/s' '2.715 MiB/s' -> 字节/秒"""
|
|
m = _SPEED_RE.search(text)
|
|
if not m:
|
|
return 0.0
|
|
return float(m.group(1)) * _UNIT_MULT[m.group(2).upper()]
|
|
|
|
|
|
def human_size(num: float) -> str:
|
|
"""字节数 -> 人类可读字符串"""
|
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
if abs(num) < 1024 or unit == "TB":
|
|
return f"{num:.1f}{unit}" if unit != "B" else f"{int(num)}B"
|
|
num /= 1024
|
|
return f"{num:.1f}TB"
|
|
|
|
|
|
def human_speed(bps: float) -> str:
|
|
return f"{human_size(bps)}/s"
|
|
|
|
|
|
def dir_size(path: str) -> int:
|
|
"""du 目录字节数 (轮询进度用, 绕过 rsync 输出缓冲)"""
|
|
if not os.path.isdir(path):
|
|
return 0
|
|
try:
|
|
proc = subprocess.run(["du", "-sb", path],
|
|
capture_output=True, text=True, timeout=30)
|
|
return int(proc.stdout.split()[0])
|
|
except (ValueError, OSError, subprocess.TimeoutExpired):
|
|
return 0
|
|
|
|
|
|
def count_files(path: str) -> int:
|
|
"""统计目录内文件数 (单文件进度条数据源)"""
|
|
if not os.path.isdir(path):
|
|
return 0
|
|
try:
|
|
proc = subprocess.run(["find", path, "-type", "f"],
|
|
capture_output=True, text=True, timeout=60)
|
|
return len([l for l in proc.stdout.splitlines() if l.strip()])
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return 0
|
|
|
|
|
|
def files_identical(path_a: str, path_b: str, chunk: int = 1 << 20) -> bool:
|
|
"""分块 md5 对比 (大文件内存安全)"""
|
|
hash_a, hash_b = hashlib.md5(), hashlib.md5()
|
|
with open(path_a, "rb") as fa, open(path_b, "rb") as fb:
|
|
while True:
|
|
ca, cb = fa.read(chunk), fb.read(chunk)
|
|
if not ca and not cb:
|
|
break
|
|
if ca != cb:
|
|
return False
|
|
hash_a.update(ca)
|
|
hash_b.update(cb)
|
|
return hash_a.digest() == hash_b.digest()
|
|
|
|
|
|
def verify_recent_files(src_dir: str, dst_dir: str, check_count: int = 5,
|
|
max_size: int = 100 * 1024 * 1024,
|
|
on_file=None) -> list[str]:
|
|
"""智能续传: 校验目标最新 N 个文件与源一致性, 返回损坏/缺失列表并删除目标坏副本
|
|
|
|
- <=max_size 的文件: 大小 + 内容 hash 全校验
|
|
- >max_size 的文件: 只查大小 (内容由后续 rsync --append 兜底)
|
|
- on_file(i, total, rel, ok): 每文件进度回调 (i 从 1 开始)
|
|
- 返回需重传的相对路径列表 (目标坏副本已删除)
|
|
"""
|
|
bad: list[str] = []
|
|
if check_count <= 0 or not os.path.isdir(dst_dir):
|
|
return bad
|
|
recent: list[tuple[float, str]] = []
|
|
for root, _dirs, files in os.walk(dst_dir):
|
|
for name in files:
|
|
full = os.path.join(root, name)
|
|
recent.append((os.path.getmtime(full), os.path.relpath(full, dst_dir)))
|
|
recent.sort(reverse=True)
|
|
# 选择最新 N 个 (按 mtime), 校验/显示顺序按路径字典序 (直观连续)
|
|
selected = sorted(rel for _mtime, rel in recent[:check_count])
|
|
total = len(selected)
|
|
for idx, rel in enumerate(selected, 1):
|
|
sp = os.path.join(src_dir, rel)
|
|
tp = os.path.join(dst_dir, rel)
|
|
if not os.path.isfile(sp):
|
|
if on_file:
|
|
on_file(idx, total, rel, True) # 源无此文件, 跳过
|
|
continue
|
|
if not os.path.isfile(tp) or os.path.getsize(sp) != os.path.getsize(tp):
|
|
bad.append(rel)
|
|
if os.path.exists(tp):
|
|
os.remove(tp)
|
|
if on_file:
|
|
on_file(idx, total, rel, False)
|
|
continue
|
|
if os.path.getsize(tp) <= max_size and not files_identical(sp, tp):
|
|
bad.append(rel)
|
|
os.remove(tp)
|
|
if on_file:
|
|
on_file(idx, total, rel, False)
|
|
elif on_file:
|
|
on_file(idx, total, rel, True)
|
|
return bad
|
|
|
|
|
|
VERIFY_LEVELS = ["ultrafast", "fast", "normal", "slow", "full"]
|
|
|
|
|
|
def _walk_files(root_dir: str) -> list[str]:
|
|
"""收集目录下所有文件完整路径"""
|
|
files: list[str] = []
|
|
for root, _dirs, names in os.walk(root_dir):
|
|
for name in names:
|
|
files.append(os.path.join(root, name))
|
|
return files
|
|
|
|
|
|
def size_check(src_dir: str, dst_dir: str, on_progress=None) -> tuple[list[str], list[str]]:
|
|
# 超快速校验: 仅比文件大小, 返回 (大小不一致文件, 目标缺失文件) 列表
|
|
diffs: list[str] = []
|
|
missing: list[str] = []
|
|
files = _walk_files(src_dir)
|
|
total = len(files)
|
|
for i, sp in enumerate(files, 1):
|
|
rel = os.path.relpath(sp, src_dir)
|
|
tp = os.path.join(dst_dir, rel)
|
|
if not os.path.isfile(tp):
|
|
missing.append(rel)
|
|
elif os.path.getsize(sp) != os.path.getsize(tp):
|
|
diffs.append(rel)
|
|
if on_progress and (i % 200 == 0 or i == total):
|
|
on_progress(i, total)
|
|
return diffs, missing
|
|
|
|
|
|
def stat_check(src_dir: str, dst_dir: str, on_progress=None) -> tuple[list[str], list[str]]:
|
|
"""fast 校验: 大小+mtime 对比, 返回 (差异文件, 缺失文件) 列表
|
|
|
|
on_progress(i, total): 进度回调
|
|
"""
|
|
diffs: list[str] = []
|
|
missing: list[str] = []
|
|
files = _walk_files(src_dir)
|
|
total = len(files)
|
|
for i, sp in enumerate(files, 1):
|
|
rel = os.path.relpath(sp, src_dir)
|
|
tp = os.path.join(dst_dir, rel)
|
|
if not os.path.isfile(tp):
|
|
missing.append(rel)
|
|
else:
|
|
sst = os.stat(sp)
|
|
tst = os.stat(tp)
|
|
if (sst.st_size != tst.st_size
|
|
or int(sst.st_mtime) != int(tst.st_mtime)):
|
|
diffs.append(rel)
|
|
if on_progress and (i % 200 == 0 or i == total):
|
|
on_progress(i, total)
|
|
return diffs, missing
|
|
|
|
|
|
def build_repair_cmd(src: str, dst: str, list_file: str,
|
|
bwlimit: str = "") -> tuple[list[str], str]:
|
|
# 构建重传问题文件的 rsync 命令 (--files-from)
|
|
src = src.rstrip("/") + "/"
|
|
dst = dst.rstrip("/") + "/"
|
|
cmd = ["rsync", "-a", "--files-from=" + list_file, "--mkpath"]
|
|
if bwlimit:
|
|
cmd += [f"--bwlimit={bwlimit}"]
|
|
cmd += [src, dst]
|
|
return cmd, list_file
|
|
|
|
|
|
def verify_files_list(src_dir: str, dst_dir: str, rels: list[str],
|
|
level: str = "slow",
|
|
on_progress=None) -> tuple[list[str], list[str]]:
|
|
"""复核指定文件列表: 按校验等级验证源/目标一致性
|
|
|
|
- ultrafast: 仅大小
|
|
- fast: 大小+mtime
|
|
- normal/slow/full: md5 内容校验
|
|
返回 (通过列表, 失败列表); on_progress(i, total) 进度回调
|
|
"""
|
|
ok: list[str] = []
|
|
fail: list[str] = []
|
|
total = len(rels)
|
|
for i, rel in enumerate(rels, 1):
|
|
sp = os.path.join(src_dir, rel)
|
|
tp = os.path.join(dst_dir, rel)
|
|
if not os.path.isfile(sp):
|
|
continue
|
|
if not os.path.isfile(tp):
|
|
fail.append(rel)
|
|
elif level == "ultrafast":
|
|
(ok if os.path.getsize(sp) == os.path.getsize(tp) else fail).append(rel)
|
|
elif level == "fast":
|
|
sst, tst = os.stat(sp), os.stat(tp)
|
|
good = (sst.st_size == tst.st_size
|
|
and int(sst.st_mtime) == int(tst.st_mtime))
|
|
(ok if good else fail).append(rel)
|
|
else:
|
|
(ok if files_identical(sp, tp) else fail).append(rel)
|
|
if on_progress:
|
|
on_progress(i, total)
|
|
return ok, fail
|
|
|
|
|
|
def read_proc_io(pid: int) -> int:
|
|
"""读取进程累计读字节数 (/proc/pid/io read_bytes)"""
|
|
try:
|
|
with open(f"/proc/{pid}/io", encoding="utf-8") as fh:
|
|
for line in fh:
|
|
if line.startswith("read_bytes"):
|
|
return int(line.split()[1])
|
|
except (OSError, ValueError):
|
|
return 0
|
|
return 0
|
|
|
|
|
|
def collect_process_tree_read_bytes(root_pid: int) -> int:
|
|
"""统计进程树(含子进程)累计读取字节 (校验进度用, 避免统计无关进程)"""
|
|
pids: set[int] = {root_pid}
|
|
changed = True
|
|
while changed:
|
|
changed = False
|
|
for pid in list(pids):
|
|
try:
|
|
with open(f"/proc/{pid}/task/{pid}/children", encoding="utf-8") as fh:
|
|
for child in fh.read().split():
|
|
c = int(child)
|
|
if c not in pids:
|
|
pids.add(c)
|
|
changed = True
|
|
except (OSError, ValueError):
|
|
pass
|
|
return sum(read_proc_io(pid) for pid in pids)
|
|
|
|
|
|
def speed_stats(history) -> tuple[float, float, float]:
|
|
"""速度统计: (当前, 峰值, 平均) 字节/秒"""
|
|
if not history:
|
|
return 0.0, 0.0, 0.0
|
|
cur = float(history[-1])
|
|
peak = max(history)
|
|
avg = sum(history) / len(history)
|
|
return cur, peak, avg
|
|
|
|
|
|
# ---------------------------------------------------------------- 数据结构
|
|
|
|
@dataclass
|
|
class ProgressStats:
|
|
"""总体进度"""
|
|
bytes_done: int = 0
|
|
bytes_total: int = 0
|
|
pct: float = 0.0
|
|
speed_bps: float = 0.0
|
|
files_done: int = 0
|
|
files_total: int = 0
|
|
|
|
@property
|
|
def eta_seconds(self) -> float:
|
|
if self.speed_bps <= 0 or self.bytes_total <= 0:
|
|
return 0.0
|
|
return max(0.0, (self.bytes_total - self.bytes_done) / self.speed_bps)
|
|
|
|
|
|
@dataclass
|
|
class FileProgress:
|
|
"""单文件进度"""
|
|
name: str = ""
|
|
pct: float = 0.0
|
|
bytes_done: int = 0
|
|
bytes_total: int = 0
|
|
|
|
|
|
@dataclass
|
|
class TransferConfig:
|
|
"""传输任务配置"""
|
|
source: str
|
|
dest: str
|
|
backend: str = "rsync"
|
|
bwlimit: str = "30000"
|
|
transfers: int = 4
|
|
resume: bool = True
|
|
checksum: bool = False
|
|
verify: bool = False
|
|
logdir: str = ""
|
|
policy: str = "overwrite"
|
|
retry: int = 0
|
|
smart_resume: bool = False
|
|
smart_check: int = 5
|
|
verify_level: str = "slow"
|
|
check_only: bool = False
|
|
repair: bool = False
|
|
|
|
|
|
# ---------------------------------------------------------------- 输出解析器
|
|
|
|
class RsyncParser:
|
|
"""解析 rsync --info=progress1/2 输出"""
|
|
PROGRESS2_RE = re.compile(
|
|
r"^\s*([\d,]+)\s+(\d+)%\s+([\d.]+[KMGTP]?i?B?/s)\s+[\d:]+\s*"
|
|
r"(?:\(xfr#(\d+),\s*to-chk=(\d+)/(\d+)\))?"
|
|
)
|
|
FILE_RE = re.compile(r"^\s*(\S+)\s+([\d,]+)\s+(\d+)%\s+([\d.]+[KMGTP]?i?B?/s)")
|
|
SUMMARY_RE = re.compile(r"^sent\s+([\d,]+)\s+bytes\s+received\s+([\d,]+)\s+bytes")
|
|
|
|
@classmethod
|
|
def feed_progress2(cls, line: str) -> ProgressStats | None:
|
|
line = clean_ansi(line).strip()
|
|
m = cls.PROGRESS2_RE.match(line)
|
|
if not m:
|
|
return None
|
|
files_done = int(m.group(4)) if m.group(4) else 0
|
|
files_total = int(m.group(6)) if m.group(6) else 0
|
|
return ProgressStats(
|
|
bytes_done=int(m.group(1).replace(",", "")),
|
|
pct=float(m.group(2)),
|
|
speed_bps=parse_speed(m.group(3)),
|
|
files_done=files_done,
|
|
files_total=files_total,
|
|
)
|
|
|
|
@classmethod
|
|
def feed_file(cls, line: str) -> FileProgress | None:
|
|
line = clean_ansi(line).strip()
|
|
m = cls.FILE_RE.match(line)
|
|
if not m:
|
|
return None
|
|
return FileProgress(name=m.group(1), pct=float(m.group(3)))
|
|
|
|
@classmethod
|
|
def is_error(cls, line: str) -> bool:
|
|
return line.startswith("rsync:") or line.startswith("rsync error")
|
|
|
|
@classmethod
|
|
def parse_summary(cls, line: str) -> tuple[int, int] | None:
|
|
m = cls.SUMMARY_RE.search(clean_ansi(line))
|
|
if not m:
|
|
return None
|
|
return (int(m.group(1).replace(",", "")), int(m.group(2).replace(",", "")))
|
|
|
|
|
|
class RcloneParser:
|
|
"""解析 rclone --progress 输出"""
|
|
FILE_RE = re.compile(r"(.+?):\s*(\d+)%\s*/\s*([\d.]+)\s+[KMGTP]?i?B")
|
|
TOTAL_RE = re.compile(
|
|
r"Transferred:\s+([\d.]+)\s+[KMGTP]?i?B\s*/\s*([\d.]+)\s+[KMGTP]?i?B,\s*(\d+)%"
|
|
)
|
|
FILES_RE = re.compile(r"Transferred:\s+(\d+)\s*/\s*(\d+),\s*(\d+)%")
|
|
|
|
@classmethod
|
|
def feed_file(cls, line: str) -> FileProgress | None:
|
|
line = clean_ansi(line).strip()
|
|
m = cls.FILE_RE.search(line)
|
|
if not m:
|
|
return None
|
|
name = m.group(1).split()[-1] if m.group(1).split() else m.group(1)
|
|
return FileProgress(name=name.strip("* "), pct=float(m.group(2)))
|
|
|
|
@classmethod
|
|
def feed_total(cls, line: str) -> ProgressStats | None:
|
|
line = clean_ansi(line).strip()
|
|
m = cls.TOTAL_RE.search(line)
|
|
if not m:
|
|
return None
|
|
done, total = float(m.group(1)), float(m.group(2))
|
|
return ProgressStats(
|
|
bytes_done=int(done * 1024 * 1024),
|
|
bytes_total=int(total * 1024 * 1024),
|
|
pct=float(m.group(3)),
|
|
speed_bps=parse_speed(line),
|
|
)
|
|
|
|
@classmethod
|
|
def feed_files(cls, line: str) -> ProgressStats | None:
|
|
line = clean_ansi(line).strip()
|
|
m = cls.FILES_RE.search(line)
|
|
if not m:
|
|
return None
|
|
return ProgressStats(
|
|
files_done=int(m.group(1)),
|
|
files_total=int(m.group(2)),
|
|
pct=float(m.group(3)),
|
|
)
|
|
|
|
@classmethod
|
|
def is_error(cls, line: str) -> bool:
|
|
low = line.lower()
|
|
return "error" in low or "failed" in low
|
|
|
|
|
|
# ---------------------------------------------------------------- 日志轮转
|
|
|
|
class LogManager:
|
|
"""日志管理: 会话级日志文件, 滚动保留最近 keep 个"""
|
|
|
|
def __init__(self, logdir: str, keep: int = 20):
|
|
self.logdir = logdir
|
|
self.keep = max(1, keep)
|
|
os.makedirs(logdir, exist_ok=True)
|
|
self.current_path: str = ""
|
|
|
|
def open_session(self, name: str) -> str:
|
|
"""创建新会话日志文件, 返回路径 (纳秒时间戳保证唯一有序)"""
|
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + f"_{time.time_ns() % 10**9:09d}"
|
|
safe = re.sub(r"[^\w.-]", "_", name)
|
|
self.current_path = os.path.join(self.logdir, f"{stamp}_{safe}.log")
|
|
with open(self.current_path, "a", encoding="utf-8") as fh:
|
|
fh.write(f"=== session {name} started at {stamp} ===\n")
|
|
return self.current_path
|
|
|
|
def write(self, text: str, level: str = "INFO") -> None:
|
|
"""实时追加一行 (带时间戳)"""
|
|
if not self.current_path:
|
|
return
|
|
stamp = datetime.now().strftime("%H:%M:%S")
|
|
with open(self.current_path, "a", encoding="utf-8") as fh:
|
|
fh.write(f"[{stamp}] [{level}] {text}\n")
|
|
|
|
def write_error(self, text: str) -> None:
|
|
self.write(text, "ERROR")
|
|
|
|
def prune(self) -> list[str]:
|
|
"""删除超出 keep 的最旧日志, 返回被删文件"""
|
|
files = sorted(glob(os.path.join(self.logdir, "*.log")))
|
|
removed = []
|
|
for f in files[:-self.keep]:
|
|
try:
|
|
os.remove(f)
|
|
removed.append(f)
|
|
except OSError:
|
|
pass
|
|
return removed
|
|
|
|
def list_logs(self) -> list[str]:
|
|
return sorted(glob(os.path.join(self.logdir, "*.log")))
|
|
|
|
|
|
# ---------------------------------------------------------------- 后端
|
|
|
|
class RsyncBackend:
|
|
name = "rsync"
|
|
|
|
@staticmethod
|
|
def build_cmd(src: str, dst: str, bwlimit: str = "30000",
|
|
resume: bool = True, checksum: bool = False,
|
|
exclude_from: str = "", skip_existing: bool = False,
|
|
append: bool = True, **_) -> list[str]:
|
|
src = src.rstrip("/") + "/"
|
|
cmd = ["rsync", "-a", "-v", "--info=progress2", "--mkpath"]
|
|
if checksum:
|
|
cmd.append("--checksum")
|
|
if skip_existing:
|
|
cmd.append("--ignore-existing")
|
|
if bwlimit:
|
|
cmd += [f"--bwlimit={bwlimit}"]
|
|
if resume:
|
|
cmd += ["--partial"]
|
|
if append:
|
|
cmd += ["--append"]
|
|
if exclude_from:
|
|
cmd += [f"--exclude-from={exclude_from}"]
|
|
cmd += [src, dst]
|
|
return cmd
|
|
|
|
|
|
@staticmethod
|
|
def build_verify_cmd(src: str, dst: str, bwlimit: str = "") -> list[str]:
|
|
"""校验命令: rsync -c --dry-run, 无输出=一致"""
|
|
src = src.rstrip("/") + "/"
|
|
cmd = ["rsync", "-a", "-c", "--dry-run", "--out-format=%i|%n"]
|
|
if bwlimit:
|
|
cmd += [f"--bwlimit={bwlimit}"]
|
|
cmd += [src, dst]
|
|
return cmd
|
|
|
|
|
|
class RcloneBackend:
|
|
name = "rclone"
|
|
|
|
@staticmethod
|
|
def build_cmd(src: str, dst: str, bwlimit: str = "8M",
|
|
transfers: int = 4, skip_existing: bool = False,
|
|
interactive: bool = False, **_) -> list[str]:
|
|
src = src.rstrip("/") + "/"
|
|
cmd = ["rclone", "copy", src, dst, "--progress",
|
|
"--log-level", "INFO"]
|
|
if bwlimit:
|
|
cmd += ["--bwlimit", bwlimit]
|
|
if skip_existing:
|
|
cmd.append("--ignore-existing")
|
|
if interactive:
|
|
cmd.append("--interactive")
|
|
cmd += ["--transfers", str(transfers)]
|
|
return cmd
|
|
|
|
|
|
class TarPvBackend:
|
|
name = "tar"
|
|
|
|
@staticmethod
|
|
def build_cmd(src: str, dst: str, bwlimit: str | None = None, **_) -> str:
|
|
src = src.rstrip("/")
|
|
dst = dst.rstrip("/")
|
|
pv_opts = f"-n -s $(du -sb {shlex.quote(src)} | cut -f1)"
|
|
if bwlimit:
|
|
# pv -L 单位是字节/秒, 参数按 KB/s 传入换算
|
|
pv_opts += f" -L {int(bwlimit) * 1024}"
|
|
return (f"tar -C {shlex.quote(src)} -vcf - . "
|
|
f"| pv {pv_opts} "
|
|
f"| tar -C {shlex.quote(dst)} -xf -")
|
|
|
|
|
|
BACKENDS = {"rsync": RsyncBackend, "rclone": RcloneBackend, "tar": TarPvBackend}
|
|
|
|
|
|
# ---------------------------------------------------------------- 冲突策略
|
|
|
|
class ConflictScanner:
|
|
"""基于 rsync --dry-run 预扫描冲突清单"""
|
|
|
|
@staticmethod
|
|
def parse_rsync_line(line: str) -> tuple[str, str] | None:
|
|
"""解析 dry-run 行 '>f.st......|123|file.txt' -> (name, 'new'|'update')"""
|
|
parts = line.split("|")
|
|
if len(parts) < 3:
|
|
return None
|
|
op = parts[0]
|
|
if not op.startswith((">f", "cf")):
|
|
return None
|
|
action = "new" if "+" in op else "update"
|
|
return (parts[2], action)
|
|
|
|
@classmethod
|
|
def scan_rsync(cls, src: str, dst: str, checksum: bool = False) -> list[tuple[str, str]]:
|
|
"""执行 dry-run, 返回 (name, action) 冲突清单"""
|
|
src = src.rstrip("/") + "/"
|
|
cmd = ["rsync", "-a", "--dry-run", "--out-format=%i|%l|%n"]
|
|
if checksum:
|
|
cmd.append("--checksum")
|
|
cmd += [src, dst]
|
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
|
return [r for line in proc.stdout.splitlines()
|
|
if (r := cls.parse_rsync_line(line))]
|
|
|
|
|
|
def apply_policy(conflicts: list[tuple[str, str]], policy: str = "overwrite",
|
|
answers: dict[str, str] | None = None) -> list[str]:
|
|
"""按策略返回需要排除(跳过)的文件名列表
|
|
|
|
answers: {文件名: 'y'|'n'} 或 {'*': 'a'(全部覆盖) | 's'(全部跳过) | 'q'(中止)}
|
|
"""
|
|
answers = answers or {}
|
|
updates = [name for name, act in conflicts if act == "update"]
|
|
if policy == "overwrite":
|
|
return []
|
|
if policy == "skip":
|
|
return updates
|
|
# ask: y/n 只影响当前项; 全局 a/s 作用于"后续"(已明确决策的保留)
|
|
global_choice = answers.get("*")
|
|
if global_choice == "a":
|
|
# 全部覆盖: 仅跳过此前已明确选 n 的
|
|
return [n for n in updates if answers.get(n) == "n"]
|
|
if global_choice == "s":
|
|
# 全部跳过: 此前已明确选 y 的仍覆盖
|
|
return [n for n in updates if answers.get(n) != "y"]
|
|
if global_choice == "q":
|
|
raise KeyboardInterrupt("用户中止传输")
|
|
return [n for n in updates if answers.get(n) != "y"]
|
|
|
|
|
|
# ---------------------------------------------------------------- 参数解析
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
prog="transfer",
|
|
description="统一文件传输工具: rsync/rclone/pv+tar, CLI/TUI 双模式",
|
|
)
|
|
parser.add_argument("source", nargs="?", help="源路径")
|
|
parser.add_argument("dest", nargs="?", help="目标路径")
|
|
parser.add_argument("--backend", choices=list(BACKENDS), default="rsync",
|
|
help="传输后端 (默认 rsync)")
|
|
parser.add_argument("--bwlimit", default="30000",
|
|
help="限速 (rsync 单位 KB/s; rclone 如 8M)")
|
|
parser.add_argument("--transfers", type=int, default=4,
|
|
help="rclone 并行传输数 (默认 4)")
|
|
parser.add_argument("--resume", action="store_true", default=True,
|
|
help="断点续传 (默认开启)")
|
|
parser.add_argument("--no-resume", dest="resume", action="store_false",
|
|
help="关闭断点续传")
|
|
parser.add_argument("--policy", choices=["overwrite", "skip", "ask"],
|
|
default=None, help="冲突策略: 覆盖/跳过/询问")
|
|
parser.add_argument("--overwrite", dest="policy", action="store_const",
|
|
const="overwrite", help="覆盖已存在的文件")
|
|
parser.add_argument("--skip", dest="policy", action="store_const",
|
|
const="skip", help="跳过已存在的文件")
|
|
parser.add_argument("--ask", dest="policy", action="store_const",
|
|
const="ask", help="遇冲突时询问")
|
|
parser.add_argument("--checksum", action="store_true",
|
|
help="rsync 校验模式 (-c)")
|
|
parser.add_argument("--verify", action="store_true",
|
|
help="传输完成后校验 (等级由 --verify-level 决定)")
|
|
parser.add_argument("--verify-level", choices=VERIFY_LEVELS, default="slow",
|
|
help=f"校验等级: {VERIFY_LEVELS} (默认 slow 全量md5)")
|
|
parser.add_argument("--check-only", action="store_true",
|
|
help="纯校验模式: 不传输, 仅按等级校验源/目标")
|
|
parser.add_argument("--repair", action="store_true",
|
|
help="校验发现问题后提示并重传问题文件")
|
|
parser.add_argument("--retry", type=int, default=0,
|
|
help="失败自动补传轮数 (-1=无限, 默认 0 不重试)")
|
|
parser.add_argument("--smart-resume", action="store_true",
|
|
help="智能续传: 校验目标最新N个文件, 损坏删除后从问题文件续传")
|
|
parser.add_argument("--smart-check", type=int, default=5, metavar="N",
|
|
help="智能续传校验文件数 (默认 5, <=100MB 全校验)")
|
|
parser.add_argument("--mode", choices=["cli", "tui"], default=None,
|
|
help="运行模式 (默认: 有任务=cli, 无任务=tui)")
|
|
parser.add_argument("--keep-logs", type=int, default=20,
|
|
help="日志保留数量 (默认 20)")
|
|
parser.add_argument("--logdir", default=os.path.expanduser("~/logs"),
|
|
help="日志目录 (默认 ~/logs)")
|
|
args = parser.parse_args(argv)
|
|
if args.policy is None:
|
|
args.policy = "overwrite"
|
|
if args.mode is None:
|
|
args.mode = "tui" # 未显式指定 --mode cli 时, 一律 TUI 启动
|
|
return args
|
|
|
|
|
|
# ---------------------------------------------------------------- 执行引擎
|
|
|
|
class TaskRunner:
|
|
"""传输任务执行器: 启动子进程, 解析输出, 实时写日志, 回调更新"""
|
|
|
|
def __init__(self, cfg: TransferConfig, lm: LogManager,
|
|
on_stats=None, on_file=None, on_log=None,
|
|
on_error=None, on_done=None, exclude_from: str = ""):
|
|
self.cfg = cfg
|
|
self.lm = lm
|
|
self.exclude_from = exclude_from
|
|
self.on_stats = on_stats # callable(ProgressStats)
|
|
self.on_file = on_file # callable(FileProgress)
|
|
self.on_log = on_log # callable(str) 普通日志行
|
|
self.on_error = on_error # callable(str) 错误行
|
|
self.on_done = on_done # callable(int rc)
|
|
self.proc: subprocess.Popen | None = None
|
|
self.stats = ProgressStats()
|
|
self._stop = threading.Event()
|
|
self._paused = False
|
|
self.stats_frames = 0
|
|
self.total_bytes = 0
|
|
self.total_files = 0
|
|
|
|
# -- 命令构建
|
|
def build_command(self) -> list[str]:
|
|
backend = BACKENDS[self.cfg.backend]
|
|
kw = dict(bwlimit=self.cfg.bwlimit or None,
|
|
resume=self.cfg.resume, checksum=self.cfg.checksum,
|
|
transfers=self.cfg.transfers,
|
|
exclude_from=self.exclude_from,
|
|
skip_existing=(self.cfg.policy == "skip"),
|
|
interactive=(self.cfg.policy == "ask"),
|
|
append=(self.cfg.policy != "ask"))
|
|
if self.cfg.backend == "tar":
|
|
return ["bash", "-c", backend.build_cmd(self.cfg.source, self.cfg.dest, **kw)]
|
|
cmd = backend.build_cmd(self.cfg.source, self.cfg.dest, **kw)
|
|
if self.cfg.backend == "rsync":
|
|
# stdbuf 行缓冲: rsync progress2 帧逐行输出, 避免管道全缓冲攒帧
|
|
return ["stdbuf", "-oL"] + cmd
|
|
return cmd
|
|
|
|
# -- 输出分发
|
|
def _dispatch(self, line: str) -> None:
|
|
if not line:
|
|
return
|
|
backend = self.cfg.backend
|
|
if backend == "rsync":
|
|
st = RsyncParser.feed_progress2(line)
|
|
if st:
|
|
self._merge_stats(st)
|
|
self._emit_stats()
|
|
return
|
|
if RsyncParser.is_error(line):
|
|
self._error(line)
|
|
return
|
|
f = RsyncParser.feed_file(line)
|
|
if f:
|
|
self._file(f)
|
|
return
|
|
summ = RsyncParser.parse_summary(line)
|
|
if summ:
|
|
self.lm.write(f"summary: sent={summ[0]}B recv={summ[1]}B")
|
|
elif backend == "rclone":
|
|
st = RcloneParser.feed_total(line)
|
|
if st:
|
|
self._merge_stats(st)
|
|
self._emit_stats()
|
|
return
|
|
st2 = RcloneParser.feed_files(line)
|
|
if st2:
|
|
self._merge_files(st2)
|
|
self._emit_stats()
|
|
return
|
|
f = RcloneParser.feed_file(line)
|
|
if f:
|
|
self._file(f)
|
|
return
|
|
if RcloneParser.is_error(line):
|
|
self._error(line)
|
|
return
|
|
else: # tar: pv -n 输出纯百分比数字, tar -v 输出文件名
|
|
stripped = line.strip()
|
|
if stripped.isdigit():
|
|
pct = float(stripped)
|
|
self.stats.pct = pct
|
|
self._emit_stats()
|
|
return
|
|
# 兜底: 普通行写日志文件 + 推送到 TUI 日志栏
|
|
if line.strip():
|
|
self.lm.write(line)
|
|
if self.on_log:
|
|
self.on_log(line)
|
|
|
|
def _merge_stats(self, st: ProgressStats) -> None:
|
|
if st.bytes_done:
|
|
self.stats.bytes_done = st.bytes_done
|
|
if st.bytes_total:
|
|
self.stats.bytes_total = st.bytes_total
|
|
if st.pct:
|
|
self.stats.pct = st.pct
|
|
if st.speed_bps:
|
|
self.stats.speed_bps = st.speed_bps
|
|
if st.files_done:
|
|
self.stats.files_done = st.files_done
|
|
if st.files_total:
|
|
self.stats.files_total = st.files_total
|
|
|
|
def _merge_files(self, st: ProgressStats) -> None:
|
|
if st.files_done:
|
|
self.stats.files_done = st.files_done
|
|
if st.files_total:
|
|
self.stats.files_total = st.files_total
|
|
|
|
def _file(self, f: FileProgress) -> None:
|
|
if self.on_file:
|
|
self.on_file(f)
|
|
if f.pct == 100:
|
|
self.lm.write(f"done: {f.name}")
|
|
|
|
def _error(self, line: str) -> None:
|
|
if self.on_error:
|
|
self.on_error(line)
|
|
self.lm.write_error(line)
|
|
|
|
def _emit_stats(self) -> None:
|
|
self.stats_frames += 1
|
|
if self.on_stats:
|
|
self.on_stats(self.stats)
|
|
|
|
# -- 执行
|
|
def run(self) -> int:
|
|
"""执行传输: 失败自动补传(retry) + 完成后校验(verify)"""
|
|
# 纯校验模式: 不传输, 直接按等级校验
|
|
if self.cfg.check_only:
|
|
self.lm.open_session(f"check_{os.path.basename(self.cfg.source.rstrip('/'))}")
|
|
self.lm.write(f"cmd: check-only level={self.cfg.verify_level}")
|
|
self.lm.write(f"mode: {self.cfg.source} -> {self.cfg.dest}")
|
|
self._verify()
|
|
return 0
|
|
rc = 0
|
|
attempt = 0
|
|
retry_label = "∞" if self.cfg.retry < 0 else str(self.cfg.retry)
|
|
while True:
|
|
if attempt > 0:
|
|
self.lm.write(f"=== 补传轮次 {attempt}/{retry_label} ===")
|
|
if self.on_log:
|
|
self.on_log(f"[yellow]补传轮次 {attempt}/{retry_label}[/]")
|
|
time.sleep(3) # 等守护恢复设备
|
|
rc = self._run_once()
|
|
if rc == 0 or self._stop.is_set():
|
|
break
|
|
attempt += 1
|
|
# retry=-1 无限重试; retry=N 重试 N 轮; retry=0 不重试
|
|
if self.cfg.retry >= 0 and attempt > self.cfg.retry:
|
|
break
|
|
self.lm.write(f"attempt {attempt} failed rc={rc}")
|
|
if self.on_error:
|
|
self.on_error(f"本轮退出码 {rc}, 将自动补传 ({attempt}/{retry_label})")
|
|
if rc == 0 and self.cfg.verify and self.cfg.backend in ("rsync",):
|
|
self._verify()
|
|
return rc
|
|
|
|
def _run_once(self) -> int:
|
|
"""单次传输, 返回退出码 (进度由目录轮询驱动, 绕过后端输出缓冲)"""
|
|
cmd = self.build_command()
|
|
self.lm.open_session(f"{self.cfg.backend}_{os.path.basename(self.cfg.source.rstrip('/'))}")
|
|
self.lm.write(f"cmd: {' '.join(cmd)}")
|
|
self.lm.write(f"mode: {self.cfg.backend} bwlimit={self.cfg.bwlimit or 'off'} "
|
|
f"resume={self.cfg.resume} checksum={self.cfg.checksum}")
|
|
# 源总大小 (进度分母)
|
|
self.total_bytes = dir_size(self.cfg.source.rstrip("/"))
|
|
self.total_files = count_files(self.cfg.source.rstrip("/"))
|
|
self.stats.files_total = self.total_files
|
|
self.lm.write(f"source size: {human_size(self.total_bytes)}, "
|
|
f"files: {self.total_files}")
|
|
# 智能续传: 校验目标最新 N 个文件, 坏文件删除后由 rsync 重传
|
|
if self.cfg.smart_resume and not self._stop.is_set():
|
|
src_dir = self.cfg.source.rstrip("/")
|
|
dst_dir = self.cfg.dest.rstrip("/")
|
|
self.lm.write(f"smart-resume: 校验目标最新 {self.cfg.smart_check} 个文件...")
|
|
if self.on_log:
|
|
self.on_log(f"[cyan]智能续传: 校验最近 {self.cfg.smart_check} 个文件...[/]")
|
|
def on_verify_file(idx: int, total: int, rel: str, ok: bool) -> None:
|
|
# 校验进度: 复用 stats 驱动进度条 + 日志栏每文件输出
|
|
self.stats.bytes_done = idx
|
|
self.stats.bytes_total = total
|
|
self.stats.pct = idx / max(1, total) * 100
|
|
self._emit_stats()
|
|
if self.on_log:
|
|
mark = "✓" if ok else "✘ 损坏"
|
|
self.on_log(f"校验 {idx}/{total}: {os.path.basename(rel)} {mark}")
|
|
|
|
bad = verify_recent_files(src_dir, dst_dir, self.cfg.smart_check,
|
|
on_file=on_verify_file)
|
|
if bad:
|
|
self.lm.write(f"smart-resume: 发现 {len(bad)} 个损坏/缺失, 将重传: {bad}")
|
|
if self.on_log:
|
|
self.on_log(f"[yellow]发现 {len(bad)} 个损坏文件, 从断点重传: "
|
|
f"{', '.join(os.path.basename(b) for b in bad[:5])}[/]")
|
|
else:
|
|
if self.on_log:
|
|
self.on_log("[green]智能续传: 最近文件校验通过[/]")
|
|
# stdout 丢弃 (进度帧缓冲不可靠), stderr 单独读错误
|
|
self.proc = subprocess.Popen(
|
|
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
text=True, bufsize=1, start_new_session=True,
|
|
)
|
|
# 轮询线程: 每秒统计目标目录大小驱动进度
|
|
self._stop.clear()
|
|
poll = threading.Thread(target=self._poll_loop, daemon=True)
|
|
poll.start()
|
|
assert self.proc.stderr is not None
|
|
for line in self.proc.stderr:
|
|
if self._stop.is_set():
|
|
break
|
|
line = line.rstrip()
|
|
if line:
|
|
self._error(line)
|
|
rc = self.proc.wait()
|
|
self._stop.set()
|
|
poll.join(timeout=3)
|
|
# 最终统计
|
|
final = dir_size(self.cfg.dest.rstrip("/"))
|
|
self.stats.bytes_done = final
|
|
if self.total_bytes:
|
|
self.stats.pct = min(100.0, final / self.total_bytes * 100)
|
|
self.lm.write(f"exit code: {rc} (帧 {self.stats_frames} 次, "
|
|
f"目标 {human_size(final)}/{human_size(self.total_bytes)})")
|
|
removed = self.lm.prune()
|
|
if removed:
|
|
self.lm.write(f"pruned old logs: {len(removed)}")
|
|
if self.on_done:
|
|
self.on_done(rc)
|
|
return rc
|
|
|
|
def _poll_loop(self) -> None:
|
|
"""每秒轮询目标目录大小, 更新进度 (独立线程)"""
|
|
dst = self.cfg.dest.rstrip("/")
|
|
last = dir_size(dst)
|
|
last_t = time.time()
|
|
poll = 0
|
|
while not self._stop.is_set():
|
|
time.sleep(1)
|
|
if self._paused:
|
|
continue # 暂停期间不轮询
|
|
done = dir_size(dst)
|
|
now = time.time()
|
|
speed = (done - last) / max(0.1, now - last_t)
|
|
self.stats.bytes_done = done
|
|
self.stats.bytes_total = self.total_bytes
|
|
self.stats.speed_bps = max(0, speed)
|
|
if self.total_bytes:
|
|
self.stats.pct = min(100.0, done / self.total_bytes * 100)
|
|
last, last_t = done, now
|
|
# 每 3 秒更新文件计数 (find 开销控制)
|
|
if self.total_files and poll % 3 == 0:
|
|
self.stats.files_done = count_files(dst)
|
|
self._emit_stats()
|
|
poll += 1
|
|
# 每 5 秒一条进度快照到日志栏
|
|
if self.on_log and poll % 5 == 0:
|
|
self.on_log(f"已传 {human_size(done)} / {human_size(self.total_bytes)} "
|
|
f"({self.stats.pct:.0f}%) {human_speed(self.stats.speed_bps)}")
|
|
|
|
def _repair_if_requested(self, issues: list[str]) -> None:
|
|
"""校验发现问题且开启 --repair: 询问后重传问题文件"""
|
|
if not issues or not self.cfg.repair or self._stop.is_set():
|
|
return
|
|
self.lm.write(f"repair: 发现 {len(issues)} 个问题文件, 等待用户确认")
|
|
if self.on_error:
|
|
self.on_error(f"校验发现 {len(issues)} 个问题文件, 是否重新传输? [y/N]")
|
|
# CLI input 交互 (TUI 通过输入栏另行处理)
|
|
try:
|
|
answer = input(f"发现 {len(issues)} 个问题文件, 重新传输? [y/N]: ").strip().lower()
|
|
except EOFError:
|
|
answer = "n"
|
|
if answer not in ("y", "yes"):
|
|
self.lm.write("repair: 用户跳过修复")
|
|
if self.on_log:
|
|
self.on_log("[yellow]跳过问题文件修复[/]")
|
|
return
|
|
self._do_repair(issues)
|
|
|
|
def _do_repair(self, issues: list[str]) -> None:
|
|
"""用 rsync --files-from 重传问题文件"""
|
|
# 先删除目标问题文件: 避免 rsync 按 size+mtime 判定相同而跳过
|
|
# (内容损坏但大小/mtime 一致的场景, 增量检测不出来)
|
|
dst_dir = self.cfg.dest.rstrip("/")
|
|
deleted = 0
|
|
for rel in issues:
|
|
tp = os.path.join(dst_dir, rel)
|
|
if os.path.exists(tp):
|
|
try:
|
|
os.remove(tp)
|
|
deleted += 1
|
|
except OSError as exc:
|
|
self.lm.write_error(f"repair: 删除 {rel} 失败: {exc}")
|
|
if deleted:
|
|
self.lm.write(f"repair: 已删除 {deleted} 个损坏目标文件")
|
|
list_path = os.path.join(tempfile.gettempdir(), "tsu_repair.txt")
|
|
with open(list_path, "w", encoding="utf-8") as fh:
|
|
fh.write("\n".join(issues) + "\n")
|
|
cmd, _ = build_repair_cmd(self.cfg.source, self.cfg.dest,
|
|
list_path, self.cfg.bwlimit)
|
|
self.lm.write(f"repair: 重传 {len(issues)} 个文件: {' '.join(cmd)}")
|
|
if self.on_log:
|
|
self.on_log(f"[cyan]重传 {len(issues)} 个问题文件...[/]")
|
|
try:
|
|
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE, text=True)
|
|
errs = "".join(proc.stderr.readlines()) if proc.stderr else ""
|
|
rc = proc.wait()
|
|
except OSError as exc:
|
|
self.lm.write_error(f"repair: 启动失败 {exc}")
|
|
return
|
|
self.lm.write(f"repair: 完成 rc={rc}")
|
|
if self.on_log:
|
|
if rc == 0:
|
|
self.on_log("[green]✔ 问题文件已重传[/]")
|
|
else:
|
|
self.on_log(f"[red]✘ 重传失败 rc={rc}: {errs.strip()[:200]}[/]")
|
|
# 重传后复核: 用前置校验等级再验一遍重传的文件
|
|
if rc == 0 and issues:
|
|
self._recheck_repaired(issues)
|
|
|
|
def _recheck_repaired(self, issues: list[str]) -> None:
|
|
"""重传后用前置校验等级复核问题文件"""
|
|
src_dir = self.cfg.source.rstrip("/")
|
|
dst_dir = self.cfg.dest.rstrip("/")
|
|
self.lm.write(f"repair: 复核 {len(issues)} 个重传文件 "
|
|
f"(level={self.cfg.verify_level})")
|
|
if self.on_log:
|
|
self.on_log(f"[cyan]复核重传文件 ({self.cfg.verify_level})...[/]")
|
|
|
|
def on_progress(i: int, total: int) -> None:
|
|
self._emit_verify_progress(i, total)
|
|
|
|
ok, fail = verify_files_list(src_dir, dst_dir, issues,
|
|
self.cfg.verify_level,
|
|
on_progress=on_progress)
|
|
self.lm.write(f"repair: 复核完成 通过={len(ok)} 失败={len(fail)}")
|
|
if self.on_log:
|
|
if not fail:
|
|
self.on_log(f"[green]✔ 复核通过: {len(ok)} 个重传文件完整[/]")
|
|
else:
|
|
self.on_log(f"[red]✘ 复核发现 {len(fail)} 个文件仍未修复: "
|
|
f"{', '.join(os.path.basename(f) for f in fail[:5])}[/]")
|
|
|
|
def _emit_verify_progress(self, i: int, total: int) -> None:
|
|
"""校验进度: 复用 stats 驱动进度条 + 每 5% 打日志"""
|
|
self.stats.bytes_done = i
|
|
self.stats.bytes_total = total
|
|
self.stats.pct = i / max(1, total) * 100
|
|
self._emit_stats()
|
|
if self.on_log and (i % max(1, total // 20) == 0 or i == total):
|
|
self.on_log(f"校验 {i}/{total} ({self.stats.pct:.0f}%)")
|
|
|
|
def _verify(self) -> None:
|
|
"""按等级校验源/目标一致性"""
|
|
level = self.cfg.verify_level
|
|
self.lm.write(f"verify: 开始校验 level={level}")
|
|
if self.on_log:
|
|
self.on_log(f"[cyan]校验中 (level={level})...[/]")
|
|
if level == "ultrafast":
|
|
self._verify_size_only()
|
|
return
|
|
if level == "normal":
|
|
self._verify_fast()
|
|
# normal: 追加最新 N 个文件 md5
|
|
src_dir = self.cfg.source.rstrip("/")
|
|
dst_dir = self.cfg.dest.rstrip("/")
|
|
n = self.cfg.smart_check or 20
|
|
self.lm.write(f"verify(normal): 追加校验最新 {n} 个文件 md5")
|
|
bad = verify_recent_files(src_dir, dst_dir, n)
|
|
if bad:
|
|
self.lm.write(f"verify(normal): {len(bad)} 个文件损坏: {bad}")
|
|
if self.on_log:
|
|
if bad:
|
|
self.on_log(f"[red]校验发现 {len(bad)} 个文件损坏[/]")
|
|
else:
|
|
self.on_log("[green]✔ 最新文件 md5 校验通过[/]")
|
|
self._repair_if_requested(bad)
|
|
return
|
|
if level == "full":
|
|
self._verify_slow()
|
|
# full: 追加文件数对比
|
|
src_dir = self.cfg.source.rstrip("/")
|
|
dst_dir = self.cfg.dest.rstrip("/")
|
|
ns, nd = count_files(src_dir), count_files(dst_dir)
|
|
self.lm.write(f"verify(full): 文件数 源={ns} 目标={nd}")
|
|
if self.on_log:
|
|
if ns == nd:
|
|
self.on_log("[green]✔ 文件数一致[/]")
|
|
else:
|
|
self.on_log(f"[red]✘ 文件数不一致: 源 {ns} vs 目标 {nd}[/]")
|
|
return
|
|
# fast / slow 共用 rsync --dry-run (fast 无 -c, slow 有 -c)
|
|
self._verify_rsync(checksum=(level == "slow"))
|
|
|
|
def _verify_size_only(self) -> None:
|
|
"""ultrafast: 仅比大小 (带进度)"""
|
|
src_dir = self.cfg.source.rstrip("/")
|
|
dst_dir = self.cfg.dest.rstrip("/")
|
|
|
|
def on_progress(i: int, total: int) -> None:
|
|
self._emit_verify_progress(i, total)
|
|
|
|
diffs, missing = size_check(src_dir, dst_dir, on_progress=on_progress)
|
|
issues = diffs + missing
|
|
self.lm.write(f"verify(ultrafast): 大小不一致={len(diffs)} 缺失={len(missing)}")
|
|
if self.on_log:
|
|
if not issues:
|
|
self.on_log("[green]✔ 校验通过: 大小全部一致[/]")
|
|
else:
|
|
self.on_log(f"[red]✘ 发现 {len(issues)} 个问题文件[/]")
|
|
self._repair_if_requested(issues)
|
|
|
|
def _verify_fast(self) -> None:
|
|
"""fast: 大小+mtime 对比 (自实现, 带进度)"""
|
|
src_dir = self.cfg.source.rstrip("/")
|
|
dst_dir = self.cfg.dest.rstrip("/")
|
|
|
|
def on_progress(i: int, total: int) -> None:
|
|
self._emit_verify_progress(i, total)
|
|
|
|
diffs, missing = stat_check(src_dir, dst_dir, on_progress=on_progress)
|
|
issues = diffs + missing
|
|
for rel in issues:
|
|
self.lm.write(f"verify diff: {rel}")
|
|
self.lm.write(f"verify(fast): 完成 差异={len(diffs)} 缺失={len(missing)}")
|
|
if self.on_log:
|
|
if not issues:
|
|
self.on_log("[green]✔ 校验通过: 无差异 (size+mtime)[/]")
|
|
else:
|
|
self.on_log(f"[red]✘ 发现 {len(issues)} 个差异/缺失[/]")
|
|
self._repair_if_requested(issues)
|
|
|
|
def _verify_rsync(self, checksum: bool) -> None:
|
|
"""slow(rsync -c)/fast 共用: dry-run 差异统计 + 进度轮询"""
|
|
self.lm.write(f"verify: 开始 ({'checksum' if checksum else 'size+mtime'})")
|
|
if self.on_log:
|
|
self.on_log("[cyan]校验中 (rsync -c --dry-run)...[/]")
|
|
cmd = RsyncBackend.build_verify_cmd(self.cfg.source, self.cfg.dest,
|
|
self.cfg.bwlimit)
|
|
try:
|
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, text=True)
|
|
except OSError as exc:
|
|
self.lm.write_error(f"verify: 启动失败 {exc}")
|
|
return
|
|
# 进度轮询: 校验读取量 = 源+目标各读一遍 (2x), 按 /proc/io 统计
|
|
total_read = self.total_bytes * 2 if self.total_bytes else 0
|
|
stop_poll = threading.Event()
|
|
|
|
def poll_verify() -> None:
|
|
last = 0
|
|
last_t = time.time()
|
|
while not stop_poll.is_set():
|
|
time.sleep(1)
|
|
done = collect_process_tree_read_bytes(proc.pid)
|
|
now = time.time()
|
|
speed = max(0, (done - last) / max(0.1, now - last_t))
|
|
last, last_t = done, now
|
|
self.stats.bytes_done = done
|
|
self.stats.bytes_total = total_read or done
|
|
if total_read:
|
|
self.stats.pct = min(100.0, done / total_read * 100)
|
|
self.stats.speed_bps = speed
|
|
self._emit_stats()
|
|
if self.on_log:
|
|
label = (f"校验 {self.stats.pct:.0f}% 已读 "
|
|
f"{human_size(done)}/{human_size(total_read)} "
|
|
f"{human_speed(speed)}") if total_read else f"校验中 已读 {human_size(done)} {human_speed(speed)}"
|
|
self.on_log(label)
|
|
|
|
poll_thread = threading.Thread(target=poll_verify, daemon=True)
|
|
poll_thread.start()
|
|
assert proc.stdout is not None
|
|
diffs = 0
|
|
issues: list[str] = []
|
|
for line in proc.stdout:
|
|
line = line.rstrip()
|
|
if not line or line.startswith(("sending", "sent ", "total ")):
|
|
continue
|
|
diffs += 1
|
|
# out-format=%i|%n, 提取文件名
|
|
if "|" in line:
|
|
issues.append(line.rsplit("|", 1)[-1])
|
|
self.lm.write(f"verify diff: {line}")
|
|
rc = proc.wait()
|
|
stop_poll.set()
|
|
poll_thread.join(timeout=2)
|
|
self.lm.write(f"verify: 完成 rc={rc} 差异文件={diffs}")
|
|
self._repair_if_requested(issues)
|
|
if self.on_log:
|
|
if diffs == 0 and rc == 0:
|
|
self.on_log("[green]✔ 校验通过: 源与目标完全一致[/]")
|
|
else:
|
|
self.on_log(f"[red]✘ 校验发现 {diffs} 个差异 (rc={rc})[/]")
|
|
|
|
def _verify_slow(self) -> None:
|
|
"""slow: rsync -c --dry-run 全量 md5 + 进度轮询"""
|
|
self._verify_rsync(checksum=True)
|
|
|
|
def pause(self) -> None:
|
|
"""暂停: SIGSTOP 整个进程组"""
|
|
if self._paused:
|
|
return
|
|
proc = self.proc
|
|
if proc and proc.poll() is None:
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGSTOP)
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
proc.send_signal(signal.SIGSTOP)
|
|
self._paused = True
|
|
|
|
def resume(self) -> None:
|
|
"""继续: SIGCONT 整个进程组"""
|
|
if not self._paused:
|
|
return
|
|
proc = self.proc
|
|
if proc and proc.poll() is None:
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGCONT)
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
proc.send_signal(signal.SIGCONT)
|
|
self._paused = False
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
if self.proc and self.proc.poll() is None:
|
|
if self._paused:
|
|
self.resume()
|
|
try:
|
|
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
self.proc.terminate()
|
|
|
|
|
|
# ---------------------------------------------------------------- 冲突交互
|
|
|
|
def interactive_ask(updates: list[tuple[str, str]]) -> dict[str, str]:
|
|
"""CLI 逐项询问: y=覆盖当前 n=跳过当前 a=全部覆盖 s=全部跳过 q=中止"""
|
|
print(f"发现 {len(updates)} 个已存在文件将被覆盖:")
|
|
answers: dict[str, str] = {}
|
|
for name, _ in updates:
|
|
while True:
|
|
choice = input(f" {name} [y]覆盖 [n]跳过 [a]全部覆盖 [s]全部跳过 [q]中止 (默认 n): ").strip().lower()
|
|
if choice in ("y", "n", "a", "s", "q"):
|
|
break
|
|
if choice == "":
|
|
choice = "n"
|
|
break
|
|
if choice == "a":
|
|
answers["*"] = "a"
|
|
break
|
|
if choice == "s":
|
|
answers["*"] = "s"
|
|
break
|
|
if choice == "q":
|
|
answers["*"] = "q"
|
|
break
|
|
answers[name] = "y" if choice == "y" else "n"
|
|
return answers
|
|
|
|
|
|
def write_exclude_file(skipped: list[str]) -> str:
|
|
"""将跳过的文件写入 exclude-from 临时文件"""
|
|
path = os.path.join(tempfile.gettempdir(), "tsu_exclude.txt")
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
fh.write("\n".join(skipped) + "\n")
|
|
return path
|
|
|
|
|
|
# ---------------------------------------------------------------- CLI 模式
|
|
|
|
def run_cli(cfg: TransferConfig, args: argparse.Namespace) -> int:
|
|
"""CLI 模式: 进度刷新到 stderr, 日志写文件"""
|
|
lm = LogManager(args.logdir, args.keep_logs)
|
|
|
|
def on_stats(st: ProgressStats) -> None:
|
|
parts = []
|
|
if st.pct:
|
|
parts.append(f"{st.pct:5.1f}%")
|
|
if st.bytes_total:
|
|
parts.append(f"{human_size(st.bytes_done)}/{human_size(st.bytes_total)}")
|
|
elif st.bytes_done:
|
|
parts.append(human_size(st.bytes_done))
|
|
if st.speed_bps:
|
|
parts.append(human_speed(st.speed_bps))
|
|
if st.eta_seconds:
|
|
parts.append(f"ETA {int(st.eta_seconds // 60)}:{int(st.eta_seconds % 60):02d}")
|
|
if st.files_total:
|
|
parts.append(f"files {st.files_done}/{st.files_total}")
|
|
sys.stderr.write("\r\033[K" + " ".join(parts))
|
|
sys.stderr.flush()
|
|
|
|
def on_file(f: FileProgress) -> None:
|
|
sys.stderr.write(f"\r\033[K[{f.pct:5.1f}%] {f.name}\n")
|
|
sys.stderr.flush()
|
|
|
|
def on_error(line: str) -> None:
|
|
sys.stderr.write(f"\r\033[K[ERROR] {line}\n")
|
|
sys.stderr.flush()
|
|
|
|
def on_log(line: str) -> None:
|
|
sys.stderr.write(f"\r\033[K{line}\n")
|
|
sys.stderr.flush()
|
|
|
|
# 冲突策略: ask 需要预扫描+交互; skip 由后端 --ignore-existing 处理
|
|
exclude_from = ""
|
|
if args.policy == "ask" and cfg.backend == "rsync":
|
|
print("[TransferSu] 预扫描冲突...")
|
|
conflicts = ConflictScanner.scan_rsync(cfg.source, cfg.dest, args.checksum)
|
|
updates = [c for c in conflicts if c[1] == "update"]
|
|
if updates:
|
|
answers = interactive_ask(updates)
|
|
skipped = apply_policy(updates, "ask", answers)
|
|
if skipped:
|
|
exclude_from = write_exclude_file(skipped)
|
|
print(f"[TransferSu] 将跳过 {len(skipped)} 个文件")
|
|
else:
|
|
print("[TransferSu] 无冲突")
|
|
elif args.policy == "ask" and cfg.backend == "rclone":
|
|
print("[TransferSu] rclone 使用原生 --interactive 交互")
|
|
|
|
runner = TaskRunner(cfg, lm, on_stats=on_stats, on_file=on_file,
|
|
on_error=on_error, on_log=on_log,
|
|
exclude_from=exclude_from)
|
|
print(f"[TransferSu] {cfg.backend}: {cfg.source} -> {cfg.dest}")
|
|
print(f"[TransferSu] 策略: {args.policy} 日志: {lm.logdir} (保留 {args.keep_logs} 个)")
|
|
try:
|
|
rc = runner.run()
|
|
except KeyboardInterrupt:
|
|
print("\n[TransferSu] 用户中断")
|
|
runner.stop()
|
|
rc = 130
|
|
finally:
|
|
sys.stderr.write("\n")
|
|
print(f"[TransferSu] 完成, 退出码 {rc}")
|
|
return rc
|
|
|
|
|
|
# ---------------------------------------------------------------- TUI 模式
|
|
|
|
from collections import deque
|
|
|
|
from textual.app import App, ComposeResult
|
|
from textual.message import Message
|
|
from textual.containers import Horizontal, Vertical
|
|
from textual.widgets import (Footer, Header, Input, ProgressBar, RichLog,
|
|
Sparkline, Static)
|
|
|
|
TUI_CSS = """
|
|
Screen { layout: vertical; }
|
|
#top { height: 7; border: round $primary; padding: 0 1; }
|
|
#total-bar, #file-bar { width: 100%; }
|
|
#total-bar Bar, #file-bar Bar { width: 1fr; }
|
|
#stats-line { height: 1; content-align: left middle; }
|
|
#chart-box { height: 6; border: round $secondary; padding: 0 1; }
|
|
#speed-stats { height: 1; color: $text-muted; }
|
|
#logs { height: 1fr; }
|
|
#file-log { width: 1fr; border: round $success; }
|
|
#error-log { width: 1fr; border: round $error; }
|
|
#cmd-bar { height: 3; border-top: solid $accent; padding: 0 1; }
|
|
Input { width: 100%; }
|
|
"""
|
|
|
|
|
|
|
|
class ScanDone(Message):
|
|
"""冲突扫描完成消息 (跨线程通知)"""
|
|
|
|
def __init__(self, cfg: TransferConfig, updates: list):
|
|
super().__init__()
|
|
self.cfg = cfg
|
|
self.updates = updates
|
|
|
|
|
|
class TransferApp(App):
|
|
"""TransferSu TUI: 上栏进度 / 中栏性能 / 下栏日志 / 底部输入栏"""
|
|
TITLE = "TransferSu"
|
|
CSS = TUI_CSS
|
|
BINDINGS = [("ctrl+c", "quit", "退出"), ("f1", "help", "帮助")]
|
|
|
|
def __init__(self, preset: TransferConfig | None = None,
|
|
logdir: str = "", keep_logs: int = 20):
|
|
super().__init__()
|
|
self.preset = preset
|
|
self.logdir = logdir or os.path.expanduser("~/logs")
|
|
self.keep_logs = keep_logs
|
|
self.lm = LogManager(self.logdir, self.keep_logs)
|
|
self.runner: TaskRunner | None = None
|
|
self.speed_history: deque[float] = deque(maxlen=80)
|
|
self._task_running = False
|
|
self._ask: dict | None = None
|
|
|
|
# -- 布局
|
|
def compose(self) -> ComposeResult:
|
|
yield Header()
|
|
with Vertical(id="top"):
|
|
yield Static("就绪: 在底部输入栏输入任务 (源 目标 [参数]) 或 help", id="stats-line")
|
|
yield ProgressBar(total=100, show_eta=True, id="total-bar")
|
|
yield ProgressBar(total=100, id="file-bar")
|
|
with Vertical(id="chart-box"):
|
|
yield Static("速度曲线 (最近 80 秒)", id="speed-title")
|
|
yield Sparkline([0], summary_function=max, id="speed-spark")
|
|
yield Static("", id="speed-stats")
|
|
with Horizontal(id="logs"):
|
|
yield RichLog(id="file-log", highlight=True, markup=True, max_lines=200)
|
|
yield RichLog(id="error-log", highlight=True, markup=True, max_lines=100)
|
|
with Horizontal(id="cmd-bar"):
|
|
yield Input(placeholder="传输任务: 源 目标 [参数] | stop / pause / continue / quit / help", id="cmd")
|
|
yield Footer()
|
|
|
|
def on_mount(self) -> None:
|
|
self.query_one("#file-log", RichLog).write("[green]文件日志[/]")
|
|
self.query_one("#error-log", RichLog).write("[red]错误日志[/]")
|
|
# 每秒定时器: 强制重绘 UI (不依赖帧回调)
|
|
self.set_interval(1.0, self._tick)
|
|
if self.preset:
|
|
self._start_task(self.preset)
|
|
|
|
def _tick(self) -> None:
|
|
"""每秒重绘: 基于 runner 缓存的 stats 刷新进度/文字"""
|
|
if self.runner and self._task_running:
|
|
self._on_stats(self.runner.stats)
|
|
|
|
# -- 输入处理
|
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
text = event.value.strip()
|
|
event.input.value = ""
|
|
if not text:
|
|
return
|
|
if self._ask is not None:
|
|
self._handle_ask_input(text)
|
|
return
|
|
low = text.lower()
|
|
if low in ("q", "quit", "exit"):
|
|
self.exit()
|
|
return
|
|
if low in ("stop", "pause", "continue", "cont", "resume"):
|
|
self._handle_transport_cmd(low)
|
|
return
|
|
if low in ("h", "help", "?"):
|
|
self._show_help()
|
|
return
|
|
if low == "clear":
|
|
self.query_one("#file-log", RichLog).clear()
|
|
self.query_one("#error-log", RichLog).clear()
|
|
return
|
|
if self._task_running:
|
|
self._flash("[yellow]任务运行中, 请先等待完成或输入 quit 退出[/]")
|
|
return
|
|
self._parse_and_start(text)
|
|
|
|
def _handle_transport_cmd(self, cmd: str) -> None:
|
|
"""传输控制命令: stop / pause / continue"""
|
|
if not self.runner:
|
|
self._flash("[yellow]当前无任务[/]")
|
|
return
|
|
if cmd == "stop":
|
|
self.runner.stop()
|
|
self._flash("[red]已发送停止信号[/]")
|
|
elif cmd == "pause":
|
|
self.runner.pause()
|
|
self._flash("[yellow]已暂停 (输入 continue 恢复)[/]")
|
|
elif cmd in ("continue", "cont", "resume"):
|
|
self.runner.resume()
|
|
self._flash("[green]已恢复[/]")
|
|
|
|
def _handle_ask_input(self, text: str) -> None:
|
|
choice = text.strip().lower()
|
|
ask = self._ask
|
|
if choice not in ("y", "n", "a", "s", "q"):
|
|
self._flash("[yellow]请输入 y/n/a/s/q[/]")
|
|
return
|
|
if choice in ("a", "s", "q"):
|
|
ask["answers"]["*"] = choice
|
|
self._ask_finish()
|
|
return
|
|
name, _ = ask["updates"][ask["idx"]]
|
|
ask["answers"][name] = "y" if choice == "y" else "n"
|
|
ask["idx"] += 1
|
|
if ask["idx"] >= len(ask["updates"]):
|
|
self._ask_finish()
|
|
else:
|
|
self._ask_next()
|
|
|
|
def _parse_and_start(self, text: str) -> None:
|
|
try:
|
|
parts = shlex.split(text)
|
|
args = parse_args(parts)
|
|
except SystemExit:
|
|
self._flash("[red]参数错误, 示例: /src /dst --backend rclone --bwlimit 8M[/]")
|
|
return
|
|
if not (args.source and args.dest):
|
|
self._flash("[red]需要源路径和目标路径[/]")
|
|
return
|
|
args.mode = "tui"
|
|
cfg = TransferConfig(
|
|
source=args.source, dest=args.dest, backend=args.backend,
|
|
bwlimit=args.bwlimit, transfers=args.transfers,
|
|
resume=args.resume, checksum=args.checksum, verify=args.verify,
|
|
logdir=self.logdir, policy=args.policy, retry=args.retry,
|
|
smart_resume=args.smart_resume, smart_check=args.smart_check,
|
|
verify_level=args.verify_level, check_only=args.check_only,
|
|
repair=args.repair,
|
|
)
|
|
if cfg.policy == "ask" and cfg.backend == "rsync":
|
|
self._flash("[yellow]预扫描冲突...[/]")
|
|
threading.Thread(target=self._scan_thread, args=(cfg,),
|
|
daemon=True).start()
|
|
return
|
|
self._start_task(cfg)
|
|
|
|
def _scan_thread(self, cfg: TransferConfig) -> None:
|
|
try:
|
|
conflicts = ConflictScanner.scan_rsync(cfg.source, cfg.dest,
|
|
cfg.checksum)
|
|
updates = [c for c in conflicts if c[1] == "update"]
|
|
except Exception as exc: # noqa: BLE001
|
|
self.call_from_thread(self._flash, f"[red]冲突扫描失败: {exc}[/]")
|
|
return
|
|
# 用消息机制回主线程 (post_message 线程安全, 处理在 app 上下文)
|
|
self.post_message(ScanDone(cfg, updates))
|
|
|
|
def on_scan_done(self, message: "ScanDone") -> None:
|
|
self._show_ask(message.cfg, message.updates)
|
|
|
|
def _show_ask(self, cfg: TransferConfig, updates: list) -> None:
|
|
if not updates:
|
|
self._flash("[green]无冲突[/]")
|
|
self._start_task(cfg)
|
|
return
|
|
# 冲突交互走底部输入栏: 逐项 y/n/a/s/q
|
|
self._ask = {"cfg": cfg, "updates": updates, "idx": 0,
|
|
"answers": {}}
|
|
self._ask_next()
|
|
|
|
def _ask_next(self) -> None:
|
|
ask = self._ask
|
|
name, _ = ask["updates"][ask["idx"]]
|
|
self.query_one("#stats-line", Static).update(
|
|
f"[bold yellow]冲突 {ask['idx'] + 1}/{len(ask['updates'])}: {name}[/]")
|
|
self.query_one("#cmd", Input).placeholder = (
|
|
f"[y]覆盖 [n]跳过 [a]全部覆盖 [s]全部跳过 [q]中止 -> {name}")
|
|
|
|
def _ask_finish(self) -> None:
|
|
ask = self._ask
|
|
self._ask = None
|
|
self.query_one("#cmd", Input).placeholder = (
|
|
"传输任务: 源 目标 [参数] | stop / pause / continue / quit / help")
|
|
try:
|
|
skipped = apply_policy(ask["updates"], "ask", ask["answers"])
|
|
except KeyboardInterrupt:
|
|
self._flash("[red]已中止传输[/]")
|
|
return
|
|
exclude_from = write_exclude_file(skipped) if skipped else ""
|
|
if skipped:
|
|
self._flash(f"[yellow]将跳过 {len(skipped)} 个文件[/]")
|
|
self._start_task(ask["cfg"], exclude_from=exclude_from)
|
|
|
|
def _on_ask_result(self, cfg: TransferConfig, updates: list,
|
|
answers: dict) -> None:
|
|
try:
|
|
skipped = apply_policy(updates, "ask", answers)
|
|
except KeyboardInterrupt:
|
|
self._flash("[red]已中止传输[/]")
|
|
return
|
|
exclude_from = write_exclude_file(skipped) if skipped else ""
|
|
if skipped:
|
|
self._flash(f"[yellow]将跳过 {len(skipped)} 个文件[/]")
|
|
self._start_task(cfg, exclude_from=exclude_from)
|
|
|
|
def _show_help(self) -> None:
|
|
self._flash(
|
|
"[cyan]用法: 源 目标 [--backend rsync|rclone|tar] [--bwlimit 30000] "
|
|
"[--resume/--no-resume] [--checksum] [--verify] [--transfers 4] | quit/clear[/]"
|
|
)
|
|
|
|
def _flash(self, msg: str) -> None:
|
|
self.query_one("#stats-line", Static).update(msg)
|
|
|
|
# -- 任务执行 (独立线程, 不依赖 textual worker 机制)
|
|
def _start_task(self, cfg: TransferConfig, exclude_from: str = "") -> None:
|
|
self._task_running = True
|
|
self._flash(f"[bold]{cfg.backend}[/] {cfg.source} -> {cfg.dest}")
|
|
threading.Thread(target=self._run_task_thread,
|
|
args=(cfg, exclude_from), daemon=True).start()
|
|
|
|
def _run_task_thread(self, cfg: TransferConfig, exclude_from: str = "") -> None:
|
|
lm = self.lm
|
|
lm.open_session(f"{cfg.backend}_{os.path.basename(cfg.source.rstrip('/'))}")
|
|
self.call_from_thread(self._flash, f"日志: {lm.current_path}")
|
|
runner = TaskRunner(
|
|
cfg, lm,
|
|
on_stats=lambda s: self.call_from_thread(self._on_stats, s),
|
|
on_file=lambda f: self.call_from_thread(self._on_file, f),
|
|
on_log=lambda l: self.call_from_thread(self._on_log, l),
|
|
on_error=lambda l: self.call_from_thread(self._on_error, l),
|
|
on_done=lambda rc: self.call_from_thread(self._on_done, rc),
|
|
exclude_from=exclude_from,
|
|
)
|
|
self.runner = runner
|
|
try:
|
|
rc = runner.run()
|
|
except Exception as exc: # noqa: BLE001
|
|
self.call_from_thread(self._on_error, f"传输异常: {exc}")
|
|
rc = 1
|
|
if rc != 0:
|
|
self.call_from_thread(self._on_error, f"传输失败, 退出码 {rc}")
|
|
|
|
# -- 回调 (主线程执行)
|
|
def _on_stats(self, st: ProgressStats) -> None:
|
|
self.query_one("#total-bar", ProgressBar).update(
|
|
progress=min(st.pct, 100.0))
|
|
if st.speed_bps:
|
|
self.speed_history.append(st.speed_bps)
|
|
self.query_one("#speed-spark", Sparkline).data = list(self.speed_history)
|
|
cur, peak, avg = speed_stats(self.speed_history)
|
|
self.query_one("#speed-stats", Static).update(
|
|
f"当前 {human_speed(cur)} | 峰值 {human_speed(peak)} | "
|
|
f"平均 {human_speed(avg)} ({len(self.speed_history)} 样本)")
|
|
parts = [f"总进度 {st.pct:5.1f}%"]
|
|
if st.bytes_total:
|
|
parts.append(f"{human_size(st.bytes_done)}/{human_size(st.bytes_total)}")
|
|
elif st.bytes_done:
|
|
parts.append(human_size(st.bytes_done))
|
|
if st.speed_bps:
|
|
parts.append(human_speed(st.speed_bps))
|
|
if st.eta_seconds:
|
|
parts.append(f"ETA {int(st.eta_seconds // 60)}:{int(st.eta_seconds % 60):02d}")
|
|
if st.files_total:
|
|
parts.append(f"文件 {st.files_done}/{st.files_total}")
|
|
self.query_one("#file-bar", ProgressBar).update(
|
|
progress=min(100.0, st.files_done / st.files_total * 100))
|
|
self.query_one("#stats-line", Static).update(" ".join(parts))
|
|
|
|
def _on_file(self, f: FileProgress) -> None:
|
|
self.query_one("#file-bar", ProgressBar).update(progress=min(f.pct, 100.0))
|
|
self.query_one("#file-log", RichLog).write(f"{f.pct:5.1f}% {f.name}")
|
|
|
|
def _on_log(self, line: str) -> None:
|
|
self.query_one("#file-log", RichLog).write(line)
|
|
|
|
def _on_error(self, line: str) -> None:
|
|
self.query_one("#error-log", RichLog).write(f"[red]{line}[/]")
|
|
|
|
def _on_done(self, rc: int) -> None:
|
|
self._task_running = False
|
|
bar = self.query_one("#total-bar", ProgressBar)
|
|
if rc == 0:
|
|
bar.update(progress=100.0) # 小文件无进度帧, 完成时强制满格
|
|
self._flash("[green]✔ 传输完成[/]")
|
|
else:
|
|
self._flash(f"[red]✘ 传输失败 (退出码 {rc}), 日志见 {self.lm.current_path}[/]")
|
|
self.query_one("#file-bar", ProgressBar).update(progress=100 if rc == 0 else 0)
|
|
|
|
|
|
# ---------------------------------------------------------------- 入口
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
if args.mode == "tui" or not (args.source and args.dest):
|
|
cfg = None
|
|
if args.source and args.dest:
|
|
cfg = TransferConfig(source=args.source, dest=args.dest,
|
|
backend=args.backend, bwlimit=args.bwlimit,
|
|
transfers=args.transfers, resume=args.resume,
|
|
checksum=args.checksum, verify=args.verify,
|
|
logdir=args.logdir, policy=args.policy,
|
|
retry=args.retry,
|
|
smart_resume=args.smart_resume,
|
|
smart_check=args.smart_check,
|
|
verify_level=args.verify_level,
|
|
check_only=args.check_only,
|
|
repair=args.repair)
|
|
TransferApp(preset=cfg, logdir=args.logdir, keep_logs=args.keep_logs).run()
|
|
return 0
|
|
cfg = TransferConfig(source=args.source, dest=args.dest,
|
|
backend=args.backend, bwlimit=args.bwlimit,
|
|
transfers=args.transfers, resume=args.resume,
|
|
checksum=args.checksum, verify=args.verify,
|
|
logdir=args.logdir, policy=args.policy,
|
|
retry=args.retry,
|
|
smart_resume=args.smart_resume,
|
|
smart_check=args.smart_check,
|
|
verify_level=args.verify_level,
|
|
check_only=args.check_only,
|
|
repair=args.repair)
|
|
return run_cli(cfg, args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|