一、需求背景
最近备份手机数据,直连电脑然后把手机上的照片全都拷出来,却遇到了几个问题:
- 直连 USB线是2.0速度,手机上100个GB的照片视频文件,拷贝花了一整个晚上。
- 在手机上删照片,在30日内还可以还原,实际上实现的原理是将这个照片文件换了一个文件名,将它标记为隐藏了,然而直连数据线将文件夹拷贝出来会将这些文件也拷出来。
- 不仅是已删除的文件,还有 .thumbnail 开头的缩略图文件、各种隐藏文件全都会被拷贝出来,这些是不需要的。
- 文件夹里有一些压缩包、文档等非媒体文件,也一并拷出来了,这些也是我不需要的。
- 我手机上的数据隔几个月就会备份一次,但是数据大部分是已经备份过的,所以每次拷贝都拷出来100GB的文件,实在是太浪费了,既浪费时间、也浪费电脑的空间。
使用NAS也许可以解决这样的问题,但是:
- 我不想使NAS
- 我也不想花钱买云服务
- NAS通常可以同步「新增」,但是不能同步「修改」和「删除」,我希望更高颗粒度地备份手机文件数据
二、解决方案
我在手机和电脑上分别写了一个跨网络传输脚本,可以在拷贝文件的时候跳过隐藏文件夹、跳过PC上已存在的文件、保持文件夹结构、还原手机上文件的修改最后时间。
2.1 首先是一个公用的 util.py 模块
- 对TCP服务进行包装,简易调用
- 写了一个File类,可以惰性获取文件的大小、MD5值、文件最后修改时间。当判断文件的文件名(不含路径)、文件大小、和MD5值完全一致时,认为是相同的文件,跳过备份。
- 服务器和客户端两端运行完之后总结统计数据。
python
import os
import socket
import struct
import hashlib
IMAGE_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'heic', 'heif', 'tiff', 'ico', 'avif']
VIDEO_EXTS = ['mp4', 'mkv', 'mov', 'avi', '3gp', 'webm', 'flv', 'm4v', 'mpg', 'mpeg']
MEDIA_EXTS = IMAGE_EXTS + VIDEO_EXTS # 图片 + 视频,供客户端遍历使用
class Tcp:
def __init__(self, addr, port):
self.host = not addr
self.addr = addr
self.port = port
self.connect()
def connect(self):
if self.host:
self.server = socket.socket()
self.server.bind(('', self.port))
self.server.listen(5)
self.client, (addr, port) = self.server.accept()
else:
self.client = socket.socket()
self.client.connect((self.addr, self.port))
def close(self):
self.client.close()
if self.host:
self.server.close()
def send(self, data):
data = data.encode() if isinstance(data, str) else data
self.client.sendall(data)
def recv(self, length):
return self.client.recv(length)
def sendlong(self, data):
data = data.encode() if isinstance(data, str) else data
assert len(data) < 1 << 32 # max 4GB
self.send(struct.pack('I', len(data)) + data)
def recvlong(self):
length = struct.unpack('I', self.recv(4))[0] # max 4GB
data = bytearray()
while len(data) < length:
chunk = self.client.recv(length - len(data))
if not chunk:
raise ConnectionError
data.extend(chunk)
return bytes(data)
class File:
def __init__(self, path: str):
self.path = os.path.normpath(path).replace(os.sep, '/')
self.dir, self.basename = os.path.split(self.path)
self.stem, self.ext = os.path.splitext(self.basename)
self.info = {}
@property
def size(self) -> int:
if 'size' not in self.info:
self.info['size'] = os.path.getsize(self.path)
return self.info['size']
@property
def md5(self) -> str:
if 'md5' not in self.info:
h = hashlib.md5()
with open(self.path, 'rb') as f:
for chunk in iter(lambda: f.read(8192), b''):
h.update(chunk)
self.info['md5'] = h.hexdigest()
return self.info['md5']
@property
def mtime(self) -> float:
if 'mtime' not in self.info:
self.info['mtime'] = os.path.getmtime(self.path)
return self.info['mtime']
@mtime.setter
def mtime(self, value: float):
os.utime(self.path, (os.path.getatime(self.path), value))
self.info['mtime'] = value
def __repr__(self) -> str:
return f"File('{self.path}')"
def __str__(self) -> str:
return self.path
def LocalIps():
"""返回本机所有可用 IPv4 地址列表"""
ips = []
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ips.append(s.getsockname()[0])
s.close()
except OSError:
pass
for info in socket.getaddrinfo(socket.gethostname(), None):
ip = info[4][0]
if ':' not in ip and ip != '127.0.0.1' and ip not in ips:
ips.append(ip)
return ips
def PrintLocalIps():
"""打印本机所有可用 IPv4,便于手机端填服务器地址"""
print('本机 IP:')
for ip in LocalIps():
print(f' {ip}')
def Walk(paths, exts=('',), hidden=True):
exts = tuple(ext.lower() for ext in exts)
for path in paths:
for root, folders, files in os.walk(path):
if not hidden:
folders[:] = [folder for folder in folders if not folder.startswith('.')]
files[:] = [file for file in files if not file.startswith('.')]
folders.sort()
files.sort()
for file in files:
if file.lower().endswith(exts):
yield File(os.path.join(root, file))
def HumanSize(n: int) -> str:
"""字节数可读化:B-PB"""
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
if n < 1024:
return f'{n:.1f} {unit}'
n /= 1024
return f'{n:.1f} PB'
def PrintSummary(st):
"""两端统一的收尾统计:发送/跳过的文件个数与总大小"""
print('-' * 40)
print('完成传输:')
print(f" 发送 {st['sent']} 个文件,总共 {HumanSize(st['sent_size'])}")
print(f" 跳过 {st['skip']} 个文件,总共 {HumanSize(st['skip_size'])}")
def RelpathOf(path: str, roots) -> str:
"""算出 path 相对其所属 root 的相对路径,便于对端还原目录结构"""
p = path.replace('\\', '/')
for r in roots:
rr = r.replace('\\', '/').rstrip('/')
if p == rr or p.startswith(rr + '/'):
rel = p[len(rr) + 1:] or os.path.basename(p)
return (os.path.basename(rr) + '/' + rel).replace('\\', '/')
return os.path.basename(path)
2.2 然后先在电脑上运行 server.py 服务器程序
运行后会自动显示本机可用IP地址:
python
import os
import time
import util
# ----- 配置区 -----
SERVER_PORT = 9000
# 预扫描的根目录;索引即在这些目录里查重
SERVER_SCAN_ROOTS = [
r'.',
r'pc',
# r'D:\Pictures',
# r'D:\Videos',
# r'E:\Photos',
]
# 接收文件落盘目录: "日期_时间" 文件夹 (如 "20260930_123000")
NEW_DIR = time.strftime('%Y%m%d_%H%M%S')
# ----- 配置区 -----
def BuildDatabase(roots):
database = list(util.Walk(roots))
print(f'已扫描本机文件 {len(database)} 个,建立索引完成。')
return database
def UniquePath(path):
root, ext = os.path.splitext(path)
i = 2
while os.path.exists(path):
path = f'{root}_{i}{ext}'
i += 1
return path
def RecvFile(tcp, relpath: str, size: int):
"""把客户端发来的内容落盘到 <落盘目录>/<相对路径>,还原文件最后修改时间"""
file = util.File(UniquePath(os.path.join(NEW_DIR, relpath)))
os.makedirs(file.dir, exist_ok=True)
with open(file.path, 'wb') as f:
received = 0
while received < size:
chunk = tcp.recv(size - received)
f.write(chunk)
received += len(chunk)
file.mtime = float(tcp.recvlong())
def RecvFiles(tcp, database):
st = {'sent': 0, 'sent_size': 0, 'skip': 0, 'skip_size': 0}
while True:
cmd = tcp.recvlong().decode()
if cmd == 'END':
break
relpath = tcp.recvlong().decode()
basename = os.path.basename(relpath)
size = int(tcp.recvlong())
md5 = tcp.recvlong().decode()
for file in database:
if file.basename == basename and file.size == size and file.md5 == md5:
tcp.sendlong('SKIP')
st['skip'] += 1
st['skip_size'] += size
print(f'跳过: {relpath} ({util.HumanSize(size)})')
break
else:
tcp.sendlong('DATA')
RecvFile(tcp, relpath, size)
st['sent'] += 1
st['sent_size'] += size
print(f'接收: {relpath} ({util.HumanSize(size)})')
util.PrintSummary(st)
def main():
util.PrintLocalIps()
database = BuildDatabase(SERVER_SCAN_ROOTS)
print(f'等待手机连接...')
tcp = util.Tcp(None, SERVER_PORT)
RecvFiles(tcp, database)
if __name__ == '__main__':
main()
2.3 再然后在手机上运行 client.py 客户端程序
输入PC的IP地址后开始备份:
python
import util
# ----- 配置区 -----
SERVER_PORT = 9000
WALK_ROOTS = [
'phone',
'/storage/emulated/0/DCIM',
'/storage/emulated/0/Pictures',
# '/storage/emulated/0/Movies',
# '/storage/emulated/0/Download',
]
CHUNK = 1 << 20 # 分块 1MB
# ----- 配置区 -----
def SendFile(tcp, path: str, size: int, chunk: int = CHUNK):
"""按 size 字节流式发送内容,1MB/次"""
sent = 0
with open(path, 'rb') as f:
while sent < size:
data = f.read(chunk)
if not data:
break
tcp.send(data)
sent += len(data)
def main():
port, roots = SERVER_PORT, WALK_ROOTS
util.PrintLocalIps()
host = input('请输入PC端IP地址: ').strip()
print(f'连接 {host}:{port} ...')
tcp = util.Tcp(host, port)
st = {'sent_size': 0, 'sent': 0, 'skip': 0, 'skip_size': 0}
for file in util.Walk(roots, exts=util.MEDIA_EXTS, hidden=False):
if file.size == 0:
continue
tcp.sendlong('FILE')
relpath = util.RelpathOf(file.path, roots)
tcp.sendlong(relpath)
tcp.sendlong(str(file.size))
tcp.sendlong(file.md5)
cmd = tcp.recvlong().decode()
if cmd == 'SKIP':
st['skip'] += 1
st['skip_size'] += file.size
print(f'跳过: {relpath} ({util.HumanSize(file.size)})')
elif cmd == 'DATA':
SendFile(tcp, file.path, file.size)
tcp.sendlong(str(file.mtime))
st['sent'] += 1
st['sent_size'] += file.size
print(f'发送: {relpath} ({util.HumanSize(file.size)})')
else:
raise ValueError(cmd)
tcp.sendlong('END')
util.PrintSummary(st)
if __name__ == '__main__':
main()
三、其他提醒
- 因为走的是WiFi,所以拷贝速度比直连数据线的USB2.0传输速度更快了。
- 如果有需要,还可以指定备份例如最近30天新增的文件,进一步加快速度(请自行修改代码)。
- 如果需要将「跳过」的文件改为从硬盘「移动」过来,例如你可能在手机上整理了文件夹分类,你可能希望同步此变化,请自行设计代码。
- Live Photo的实现原理是一个同名的图片和视频文件存放在了一个文件夹内,所以实际拷贝的文件数目 可能多于手机上显示的本机照片数量。本版本未实现去除这部分计数统计的方法,请自行设计,或者以后我有空了可能考虑加进去。
- 本软件不限于在Android手机和PC之间备份,在手机↔手机、PC↔PC之间也可以传送备份,但是在它们之间使用的意义可能不是很大。
- 本软件具备简单调试功能,所以未增加异常处理和断点续传功能,备份时请保持网络通畅。如果发生异常报错,为保证文件完整,建议从新开始备份。
- 这个脚本将来可能会更新,如果发生更新将会同步在以下地址,本文不再更新:znsoooo/tolkits/数据传输/手机同步