1005 lines
38 KiB
Python
1005 lines
38 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 subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import tempfile
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from glob import glob
|
|
|
|
# ---------------------------------------------------------------- 工具函数
|
|
|
|
_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 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"
|
|
|
|
|
|
# ---------------------------------------------------------------- 输出解析器
|
|
|
|
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", "--info=progress2"]
|
|
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
|
|
|
|
|
|
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="传输完成后校验")
|
|
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()
|
|
|
|
# -- 命令构建
|
|
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:
|
|
if self.on_stats:
|
|
self.on_stats(self.stats)
|
|
|
|
# -- 执行
|
|
def run(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.proc = subprocess.Popen(
|
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
text=True, bufsize=0,
|
|
)
|
|
# rsync progress2 / rclone --progress 用 \r 刷新帧(不换行),
|
|
# 故按 \r 与 \n 均作分隔符分帧解析, 避免进度只取到末帧
|
|
assert self.proc.stdout is not None
|
|
buf = ""
|
|
while True:
|
|
chunk = self.proc.stdout.read(8192)
|
|
if not chunk:
|
|
break
|
|
buf += chunk
|
|
while True:
|
|
m = re.search(r"[\r\n]", buf)
|
|
if not m:
|
|
break
|
|
seg, buf = buf[: m.start()], buf[m.start() + 1:]
|
|
if self._stop.is_set():
|
|
self.proc.kill()
|
|
break
|
|
self._dispatch(seg.rstrip())
|
|
if buf.strip() and not self._stop.is_set():
|
|
self._dispatch(buf.rstrip())
|
|
if self._stop.is_set():
|
|
self.proc.kill()
|
|
rc = self.proc.wait()
|
|
self.lm.write(f"exit code: {rc}")
|
|
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 stop(self) -> None:
|
|
self._stop.set()
|
|
if self.proc and self.proc.poll() is None:
|
|
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()
|
|
|
|
# 冲突策略: 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, 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)
|
|
yield RichLog(id="error-log", highlight=True, markup=True)
|
|
with Horizontal(id="cmd-bar"):
|
|
yield Input(placeholder="传输任务: 源路径 目标路径 [--backend rsync|rclone|tar --bwlimit 30000 ...] | help / quit", 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]错误日志[/]")
|
|
if self.preset:
|
|
self._start_task(self.preset)
|
|
|
|
# -- 输入处理
|
|
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 ("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_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,
|
|
)
|
|
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 = (
|
|
"传输任务: 源路径 目标路径 [--backend rsync|rclone|tar --bwlimit 30000 ...] | help / quit")
|
|
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("#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)
|
|
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)
|
|
return run_cli(cfg, args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|