Files
7z-encrypt/backup.py
T
lou e6d12e0417 备份支持指定目录: sz-backup --dir <路径> (PC 端任意目录, 不限于预设项)
- backup() 加 srcs 参数: None=预设/配置目录, 显式传=备份指定目录
- CLI 加 --dir: 目录 basename 作为备份项名, restore 同名恢复
- TUI 菜单 13: 输入目录路径直接备份指定目录, 预设项不变
2026-08-11 14:47:54 +08:00

263 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""一键备份: 相册/聊天记录/文档 -> 私有加密服务器
整个文件夹打包一个文件: tar -> 32MB 卷压缩 (逐卷 7z + 逐卷加密) -> aria2 上传。
不分批次, 一个文件夹 = 一个文件 (file_id)。
手机 ZeroTermux/PRoot 可用: /sdcard/DCIM 等外部存储直接可见。
聊天记录注意: PRoot 无 root 读不到微信数据库 (/data/data), 需先把
微信"备份与迁移/导出"的文件放到手机存储, 再配置 backup.chat 目录。
用法:
sz-backup # 备份全部 (photos+documents+download+chat)
sz-backup photos # 只备份相册
sz-backup list # 已备份清单
sz-backup restore photos # 恢复相册
"""
import argparse
import json
import os
import shutil
import subprocess
import tarfile
from datetime import datetime
from pathlib import Path
from typing import Any
from tqdm import tqdm
STATE_DIR = Path.home() / ".local/share/7z-encrypt" / "backup"
VOL_MB = 32 # 卷压缩粒度钉死 32MB (与上传一致)
def _cfg() -> dict[str, Any]:
"""读配置文件 backup 段 (XDG, 同客户端)"""
env = os.environ.get("SZ_CONFIG")
if env:
p = Path(env).expanduser()
else:
xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
p = Path(xdg) / "7z-encrypt" / "config.json"
try:
return dict(json.loads(p.read_text(encoding="utf-8")).get("backup", {}))
except (OSError, json.JSONDecodeError):
return {}
def _server() -> str:
"""服务器地址: 配置 server.url (备份复用客户端配置)"""
env = os.environ.get("SZ_CONFIG")
if env:
p = Path(env).expanduser()
else:
xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
p = Path(xdg) / "7z-encrypt" / "config.json"
try:
return str(json.loads(p.read_text(encoding="utf-8")).get("server", {}).get("url", ""))
except (OSError, json.JSONDecodeError):
return ""
def _token() -> str:
"""登录 token: 配置文件 server.token"""
env = os.environ.get("SZ_CONFIG")
if env:
p = Path(env).expanduser()
else:
xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
p = Path(xdg) / "7z-encrypt" / "config.json"
try:
return str(json.loads(p.read_text(encoding="utf-8")).get("server", {}).get("token", ""))
except (OSError, json.JSONDecodeError):
return ""
def _src_dirs(name: str) -> list[Path]:
"""备份源目录: 配置 override 默认"""
defaults = {
"photos": ["~/storage/dcim", "~/storage/pictures", "/sdcard/DCIM", "/sdcard/Pictures"],
"documents": ["/sdcard/Documents"],
"download": ["/sdcard/Download"],
"chat": [],
}
dirs = _cfg().get(name, defaults.get(name, []))
return [Path(d).expanduser() for d in dirs]
def _manifest_path(name: str) -> Path:
return STATE_DIR / f"{name}.json"
def _load_manifest(name: str) -> dict[str, Any]:
try:
return json.loads(_manifest_path(name).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _save_manifest(name: str, m: dict[str, Any]) -> None:
_manifest_path(name).parent.mkdir(parents=True, exist_ok=True)
_manifest_path(name).write_text(json.dumps(m, ensure_ascii=False, indent=1), encoding="utf-8")
def _cleanup(tar_path: Path) -> None:
"""清理 tar 及其卷压缩产物 (chunk/init/manifest)"""
for p in tar_path.parent.glob(tar_path.name + "*"):
try:
p.unlink(missing_ok=True)
except OSError:
pass
def backup(name: str, chunk_mb: int = VOL_MB, srcs: list[Path] | None = None) -> int:
"""整个文件夹 -> 单个 tar -> 32MB 卷压缩 (逐卷 7z + 逐卷加密) -> aria2 上传
一个文件夹 = 一个文件 (file_id), 不分批次。
srcs 为 None 时用预设/配置源目录 (photos/documents/...), 显式传入则
备份指定目录 (PC 端任意路径, 如 /home/lou/图片)。
"""
from metadata import _build_vol_init
import argparse as _ap
from transfer import TransferClient
if srcs is None:
srcs = [s for s in _src_dirs(name) if s.exists()]
else:
srcs = [s for s in srcs if s.is_dir()]
files = [p for s in srcs for p in s.rglob("*") if p.is_file()]
if not files:
print(f"[备份] {name}: 源目录为空")
return 1
total = sum(f.stat().st_size for f in files)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
tar_path = STATE_DIR / f"{name}_{ts}.tar"
tar_path.parent.mkdir(parents=True, exist_ok=True)
# 1. 整个文件夹打包 (磁盘, 不落 tmpfs)
print(f"[备份] {name}: {len(files)} 个文件 ({total / 1048576:.1f} MB), 打包 tar ...")
with tarfile.open(tar_path, "w") as tf:
for f in files:
try:
tf.add(f, arcname=str(f))
except (OSError, tarfile.TarError) as e:
print(f" [跳过] {f}: {e}")
# 2. 32MB 卷压缩: 逐卷 7z (mx=1 快速, 备份照片视频为主) + 逐卷加密
ns = _ap.Namespace(
vol_compress=chunk_mb, vol_no_7z=False, vol_mx=1,
key_id="default_key", file_name=f"{name}_{ts}.tar", no_encrypt_name=False,
)
print(f"[备份] 卷压缩 ({chunk_mb}MB 切卷 + 逐卷 7z + 逐卷加密) ...")
if _build_vol_init(tar_path, ns) != 0:
print("[备份] 卷压缩/加密失败")
_cleanup(tar_path)
return 1
tar_path.unlink(missing_ok=True) # 明文 tar 用完即删
# 3. aria2 上传 (复用传输管线)
init_json = json.loads(Path(str(tar_path) + ".init.json").read_text(encoding="utf-8"))
manifest = json.loads(Path(str(tar_path) + "_manifest.json").read_text(encoding="utf-8"))
chunk_files = {ch["index"]: Path(ch["filename"]).resolve() for ch in manifest}
client = TransferClient(_server(), token=_token())
try:
receipt = client.transfer(init_json, chunk_files)
except Exception as e:
print(f"[备份] 上传失败: {e}")
_cleanup(tar_path)
return 1
fid = str(receipt["file_id"])
# 4. manifest 记录 (一个备份集 = 一个 file_id)
_save_manifest(name, {
"file_id": fid, "name": name, "size": total, "time": ts,
"files": [str(f) for f in files],
})
_cleanup(tar_path)
print(f"[备份] {name} 完成: {len(files)} 个文件 -> file_id={fid} (卷压缩 mx=1)")
return 0
def restore(name: str, out_root: Path | None = None) -> int:
"""下载单文件 (卷模式自动解密+7z x+拼接) -> tar 解包还原"""
from transfer import TransferClient
m = _load_manifest(name)
fid = m.get("file_id")
if not fid:
print(f"[恢复] {name}: 无备份记录 (先 sz-backup {name})")
return 1
out_dir = out_root or Path.cwd()
out_dir.mkdir(parents=True, exist_ok=True)
client = TransferClient(_server(), token=_token())
print(f"[恢复] {name}: 下载 + 解密 + 拼接 (file_id={fid}) ...")
try:
enc_path, enc_params = client.download(fid, out_dir / ".sz-restore.tar")
except Exception as e:
print(f"[恢复] 下载失败: {e}")
return 1
tar_src = out_dir / str(enc_params.get("file_name", f"{name}.tar"))
shutil.move(enc_path, tar_src)
print(f"[恢复] 解包 {tar_src} ...")
with tarfile.open(tar_src) as tf:
for member in tf.getmembers():
if out_root is not None:
# 解到 out_root 下 (member.name 是绝对路径, lstrip 后成相对路径)
tf.extract(member, path=out_root, filter="data")
else:
tf.extract(member, path=Path(member.name).parent, filter="data")
tar_src.unlink(missing_ok=True)
print(f"[恢复] {name} 完成")
return 0
def list_backups() -> int:
if not STATE_DIR.exists():
print("[备份] 暂无备份记录")
return 0
for mp in sorted(STATE_DIR.glob("*.json")):
name = mp.stem
m = _load_manifest(name)
fid = m.get("file_id", "-")
print(f" {name}: file_id={fid}, {len(m.get('files', []))} 文件, "
f"{m.get('size', 0) / 1048576:.1f} MB, {m.get('time', '-')}")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="一键备份: 相册/聊天记录/文档/指定目录 -> 加密服务器")
ap.add_argument("action", nargs="?", default="all", help="备份项: photos/documents/download/chat/all/list/restore")
ap.add_argument("target", nargs="?", help="restore 的备份项")
ap.add_argument("--dir", help="备份指定目录 (不限于预设项, 如 /home/lou/图片)")
ap.add_argument("--chunk-size", type=int, default=VOL_MB, help=f"卷压缩 MB (默认 {VOL_MB})")
args = ap.parse_args()
if args.action == "list":
return list_backups()
if args.action == "restore":
if not args.target:
print("[错误] 用法: sz-backup restore <备份项名>")
return 1
return restore(args.target)
if args.dir:
p = Path(args.dir).expanduser()
if not p.is_dir():
print(f"[错误] 目录不存在: {p}")
return 1
return backup(p.name, args.chunk_size, srcs=[p])
names = ["photos", "documents", "download", "chat"] if args.action == "all" else [args.action]
if args.action not in ("photos", "documents", "download", "chat", "all"):
print(f"[错误] 未知备份项: {args.action} (photos/documents/download/chat/all)")
return 1
ok = True
for n in names:
if not _src_dirs(n):
print(f"[备份] {n}: 无源目录 (config backup.{n} 为空, 跳过)")
continue
ok = backup(n, args.chunk_size) == 0 and ok
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())