feat: --smart-resume 智能断点续传 (校验最新N个文件, 损坏即重传)
This commit is contained in:
1 parent
15ed4330ae
commit
0f9866a031
3 files changed
+142
-2
No files matched your search
+78
-2
@@ -22,6 +22,7 @@ import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from glob import glob
|
||||
import hashlib
|
||||
|
||||
# ---------------------------------------------------------------- 工具函数
|
||||
|
||||
@@ -80,6 +81,54 @@ def count_files(path: str) -> int:
|
||||
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) -> list[str]:
|
||||
"""智能续传: 校验目标最新 N 个文件与源一致性, 返回损坏/缺失列表并删除目标坏副本
|
||||
|
||||
- <=max_size 的文件: 大小 + 内容 hash 全校验
|
||||
- >max_size 的文件: 只查大小 (内容由后续 rsync --append 兜底)
|
||||
- 返回需重传的相对路径列表 (目标坏副本已删除)
|
||||
"""
|
||||
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)
|
||||
for _mtime, rel in recent[:check_count]:
|
||||
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) or os.path.getsize(sp) != os.path.getsize(tp):
|
||||
bad.append(rel)
|
||||
if os.path.exists(tp):
|
||||
os.remove(tp)
|
||||
continue
|
||||
if os.path.getsize(tp) <= max_size and not files_identical(sp, tp):
|
||||
bad.append(rel)
|
||||
os.remove(tp)
|
||||
return bad
|
||||
|
||||
|
||||
def speed_stats(history) -> tuple[float, float, float]:
|
||||
"""速度统计: (当前, 峰值, 平均) 字节/秒"""
|
||||
if not history:
|
||||
@@ -132,6 +181,8 @@ class TransferConfig:
|
||||
logdir: str = ""
|
||||
policy: str = "overwrite"
|
||||
retry: int = 0
|
||||
smart_resume: bool = False
|
||||
smart_check: int = 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 输出解析器
|
||||
@@ -443,6 +494,10 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
help="传输完成后校验 (-c)")
|
||||
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,
|
||||
@@ -626,6 +681,22 @@ class TaskRunner:
|
||||
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} 个文件...[/]")
|
||||
bad = verify_recent_files(src_dir, dst_dir, self.cfg.smart_check)
|
||||
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,
|
||||
@@ -1013,6 +1084,7 @@ class TransferApp(App):
|
||||
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,
|
||||
)
|
||||
if cfg.policy == "ask" and cfg.backend == "rsync":
|
||||
self._flash("[yellow]预扫描冲突...[/]")
|
||||
@@ -1177,7 +1249,9 @@ def main() -> int:
|
||||
transfers=args.transfers, resume=args.resume,
|
||||
checksum=args.checksum, verify=args.verify,
|
||||
logdir=args.logdir, policy=args.policy,
|
||||
retry=args.retry)
|
||||
retry=args.retry,
|
||||
smart_resume=args.smart_resume,
|
||||
smart_check=args.smart_check)
|
||||
TransferApp(preset=cfg, logdir=args.logdir, keep_logs=args.keep_logs).run()
|
||||
return 0
|
||||
cfg = TransferConfig(source=args.source, dest=args.dest,
|
||||
@@ -1185,7 +1259,9 @@ def main() -> int:
|
||||
transfers=args.transfers, resume=args.resume,
|
||||
checksum=args.checksum, verify=args.verify,
|
||||
logdir=args.logdir, policy=args.policy,
|
||||
retry=args.retry)
|
||||
retry=args.retry,
|
||||
smart_resume=args.smart_resume,
|
||||
smart_check=args.smart_check)
|
||||
return run_cli(cfg, args)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user