在手机上运行Python增量备份手机数据到PC电脑

一、需求背景

最近备份手机数据,直连电脑然后把手机上的照片全都拷出来,却遇到了几个问题:

  1. 直连 USB线是2.0速度,手机上100个GB的照片视频文件,拷贝花了一整个晚上。
  2. 在手机上删照片,在30日内还可以还原,实际上实现的原理是将这个照片文件换了一个文件名,将它标记为隐藏了,然而直连数据线将文件夹拷贝出来会将这些文件也拷出来。
  3. 不仅是已删除的文件,还有 .thumbnail 开头的缩略图文件、各种隐藏文件全都会被拷贝出来,这些是不需要的。
  4. 文件夹里有一些压缩包、文档等非媒体文件,也一并拷出来了,这些也是我不需要的。
  5. 我手机上的数据隔几个月就会备份一次,但是数据大部分是已经备份过的,所以每次拷贝都拷出来100GB的文件,实在是太浪费了,既浪费时间、也浪费电脑的空间。

使用NAS也许可以解决这样的问题,但是:

  1. 我不想使NAS
  2. 我也不想花钱买云服务
  3. NAS通常可以同步「新增」,但是不能同步「修改」和「删除」,我希望更高颗粒度地备份手机文件数据

二、解决方案

我在手机和电脑上分别写了一个跨网络传输脚本,可以在拷贝文件的时候跳过隐藏文件夹、跳过PC上已存在的文件、保持文件夹结构、还原手机上文件的修改最后时间。

2.1 首先是一个公用的 util.py 模块

  1. 对TCP服务进行包装,简易调用
  2. 写了一个File类,可以惰性获取文件的大小、MD5值、文件最后修改时间。当判断文件的文件名(不含路径)、文件大小、和MD5值完全一致时,认为是相同的文件,跳过备份。
  3. 服务器和客户端两端运行完之后总结统计数据。
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()

三、其他提醒

  1. 因为走的是WiFi,所以拷贝速度比直连数据线的USB2.0传输速度更快了。
  2. 如果有需要,还可以指定备份例如最近30天新增的文件,进一步加快速度(请自行修改代码)。
  3. 如果需要将「跳过」的文件改为从硬盘「移动」过来,例如你可能在手机上整理了文件夹分类,你可能希望同步此变化,请自行设计代码。
  4. Live Photo的实现原理是一个同名的图片和视频文件存放在了一个文件夹内,所以实际拷贝的文件数目 可能多于手机上显示的本机照片数量。本版本未实现去除这部分计数统计的方法,请自行设计,或者以后我有空了可能考虑加进去。
  5. 本软件不限于在Android手机和PC之间备份,在手机↔手机、PC↔PC之间也可以传送备份,但是在它们之间使用的意义可能不是很大。
  6. 本软件具备简单调试功能,所以未增加异常处理和断点续传功能,备份时请保持网络通畅。如果发生异常报错,为保证文件完整,建议从新开始备份。
  7. 这个脚本将来可能会更新,如果发生更新将会同步在以下地址,本文不再更新:znsoooo/tolkits/数据传输/手机同步

​

相关推荐
Y3815326625 小时前
行业动态周报:用新闻 API 追踪一个赛道的声音变化
python·搜索引擎
零域码客5 小时前
零基础Python爬虫入门:从HTTP协议到 requests + BeautifulSoup 实战
爬虫·python·http
“AI国潮设计-小江”5 小时前
【SDXL实战】用AI生成“财神爷蛋糕×英歌舞人物”潮汕国潮甜品IP,附Prompt与批量生成思路
开发语言·人工智能·python·aigc
DD云验证5 小时前
DD云验证,免费网络验证系统
服务器·python·易语言·网络验证·卡密验证
茶栀(*´I`*)5 小时前
【Python数据分析利器】Pandas从入门到实战:核心数据结构DataFrame与Series全解析
python·数据分析·pandas
码云数智-大飞5 小时前
新手写 Python 代码,如何规范命名、减少 Bug
开发语言·python·php
天天被压力6 小时前
【别再到处找免费股票数据API了:官方204个接口,32篇一次讲透 #06】Python实时行情总报错?五档盘口+逐笔一次跑通
java·人工智能·python
智能RPA6 小时前
农业与矿业行业智能体自动化平台对比评测(计量与巡检场景)
运维·人工智能·python·自动化·agent·rpa
溪语流沙6 小时前
Django + Vue电商项目第005讲:后端骨架|Django初始化、配置分层与DRF接入
vue.js·后端·python·django
生如夏花爱学习229666 小时前
贴心使用的语音生活小助手
android