36,000 张照片搬家记:从 Google Takeout 到一刻相册
文章目录
摘要:把 Google Takeout 导出的 36,000 多个照片/视频迁到一刻相册,听起来只是"上传"一件事,实际做下来是分类、上传、限流对抗、时间还原、去重、删旧、验收七个环节的工程问题。本文按时间顺序复盘每一步的做法与踩过的坑,重点不是某个工具怎么用,而是如何在"没有官方 API、数据量大、错误不可逆"的约束下把事情做对。
一、背景
Google Photos 里攒了十几年的照片,用 Takeout 导出后躺在移动硬盘上:约 36,000 个文件,分在 PVGX、PXGV、PVGVV 等若干目录里(Takeout 的分卷命名),每个媒体文件旁边可能跟着一个 .supplemental-metadata.json,记录着 Google 认定的拍摄时间、地理信息等。
目标:全部搬进一刻相册(百度网盘的相册产品),并且拍摄时间必须正确——照片一旦按错误日期落进时间线,找起来就废了。
网页端上传试过,大批量时经常中断,且无法按自己的规则处理时间。于是改用命令行直连接口(社区项目 pybaiduphoto 已经把协议封装好)。
约束条件很苛刻:
- 没有官方开放 API,随时可能限流或失效;
- 数据量大(4GB 只是其中一批坏视频的体积);
- 云端删除不可逆,一旦删错没有任何回收站兜底。
这三条决定了后面所有设计:一切以"可断点、可回滚、可验证"为前提。
二、第一步:先分类,再动手
拿到 36,000 个文件,第一反应是"传就完了",但盲传会把问题带进云端,之后再修成本翻倍。所以先写了个评估脚本 assess_folder.py,对每个目录做一次体检:
- 读同名
.supplemental-metadata.json,取出photoTakenTime(Google 的权威拍摄时间); - 计算文件 md5,去云端清单里比对;
- 比对"云端已记录的拍摄时间"与"Google 记录的时间"是否一致。
按结果把文件分成几类:
| 分类 | 含义 | 处置 |
|---|---|---|
| 没问题 | 云端已有且时间一致,或本地时间可信 | 直接上传 |
| 可能有问题 | 云端时间存疑/无 json | 人工评估后再定 |
| 有问题 | 确认异常 | 单独处理或舍弃 |
评估产出一份 assessment_*.txt,人看了拍板,脚本才动手。这一步的价值在于:把"36,000 个未知"变成"几类已知问题",后面每一步都能针对性处理。
其中 异常/PXGX(160 个)和 异常/PXGV_有问题(1,295 个)经确认是彻底无用的损坏件,用户拍板永久舍弃,不参与上传。
经验:批量处理任何数据前,先花 10% 的时间做分类评估。分类不是拖延,是把不确定性前置。
三、上传链路
3.1 三次握手
文件上传走的是标准的三段式协议:
|
|
precreate 会带文件的 md5 与大小去询问服务端:
return_type=1:服务端没有,需要真正上传;return_type=3:命中去重,秒传完成。
这个返回值后面被我用作免费的查询接口(见第八节)。
3.2 断点续传
上传器 yike_upload.py 的设计:
- 每个文件成功后追加一行到
yike_upload_log.tsv(时间 / 路径 / 大小 / md5 / 状态); - 下次启动先读日志,已成功的(路径 + md5 同时匹配)直接跳过;
- 日志里区分
ok/dup/skip/fail四种状态。
|
|
用 md5 而不是"文件出现过"做判断很关键——后面我们会反复修改文件字节再重传,如果只认路径,重传就会被错误跳过。
3.3 大文件分片
大于 32MB 的文件一次性 POST 会被服务端掐断。补丁 bigfile_patch.py 改成 4MB 一片的分片上传。注意补丁内部不能再传 timeout 参数,会和已有的请求封装冲突。
四、时间:最容易出错、也最致命的一环
照片迁移的核心不是"传上去",而是"落在正确的时间点上"。这里踩了三个坑。
4.1 Google 给的是 UTC
.supplemental-metadata.json 里的 photoTakenTime.timestamp 是 UTC 秒。直接当成本地时间用,会整体差 8 小时,遇到跨天的照片连日期都错。
|
|
4.2 图片认 EXIF,视频认 mvhd
服务端解析拍摄时间的规则并不统一:
- 图片:读 EXIF 的
DateTimeOriginal; - mp4 / mov:读容器里
moov → mvhd的creation_time; - AVI:只认文件头里的
IDIT标记(相机写入的墙钟时间),你传什么元数据它都不理。
也就是说,改文件系统的 mtime 完全没用,唯一可控的是改文件内部的字节。这条结论后来救了 600 个视频(见 6.4)。
顺带一个坑:mvhd 的 creation_time 用的是 Mac 1904 纪元,不是 Unix 纪元:
|
|
4.3 mvhd 解析 bug
批量校验时发现 620 个"云端时间和本地不一致"的视频,一度以为上传出错了。逐条核对后发现:大部分是云端反而正确,是我们的解析代码错了——fix_media_info 里解析 mvhd 的 creation 与 modification 时取错了字段顺序。修掉解析 bug 后,620 个里 556 个自动对齐。
经验:当"不一致"的数量很大时,先怀疑自己的比对代码,而不是数据。
五、限流 errno 50005:与其对抗,不如配合
传到几千个时开始报错:errno 50005 操作过于频繁。
排查过程:
- 换 cookie —— 无效(cookie 本身有效,其他请求正常);
- 换
clienttype—— 无效,只有70是合法值,其余返回errno=2; - 观察错误发生的位置:只在最后一步
create失败,precreate和superfile2都正常。
结论:这是账号级的上传限流,没有绕过办法。但实测它是间歇放行的——手动重试 4 次大约能成功 1 次。这意味着"长时间干等"是错的选择,正确策略是短间隔反复试探,别错过放行窗口。
最终退避策略:
|
|
配合断点续传,脚本被限流打断后直接重跑即可继续,不需要人工介入。整个 PVGVV(22,239 个文件)就是这么一夜夜跑完的。
经验:对抗型限流没有银弹,能做的是「快速失败 + 指数退避 + 断点续传」三件套,把"被限流"从一个事故降级成一次重试。
六、异常照片处理
6.1 实况照片的"三件套重复"
iPhone 的实况照片(Live Photo)在 Takeout 里不会合成单个文件,而是被拆成两份:一张静态帧(HEIC/JPG)+ 一段 2~3 秒的视频(MP4/MOV)。而用户自己从 App 上传的是 Apple 原始包 .livp。
结果同一秒凑齐三条:.livp + .HEIC + .MP4,md5 各不相同,校验脚本不会报警。
处理方案(用户拍板):保留 .livp,删掉 Google 拆出来的那两份。
配对时踩了两个坑:
- Takeout 的静态帧与同名视频拍摄时间系统性差 1 秒(视频早 1 秒),连拍时还会错位一格(HEIC=IMG_0639 配 MP4=IMG_0640)。所以不能用"同一秒"硬碰,必须给 ±5 秒容差;
- 光有时间容差会误伤"用户真视频 + 同名封面图"。于是加了一道时长锁:实况片段的真实时长在 0.5~6 秒之间,超过就不配对。
|
|
反例很能说明问题:云端有 6 个不同年份的文件都叫 IMG_0574,只看文件名必误配。
这一类前后做了四轮,合计清理 270 组 / 540 条,释放约 21.8 GB,实况本体全部完好。
6.2 无 livp 的"照片 + 实况片段"二选一
还有一批只有静态图 + 视频片段、没有 .livp 的:4,699 组。判断依据是分辨率——静态图 4032×3024(1200 万像素),视频 1920×1440(280 万像素),视频是低一档的衍生物,而且体量是照片的两倍。
用户拍板:删 2~3 秒视频,保留静态照片。执行 4,683 条,零失败,释放 20 GB。
安全判据又加了一层:配对的照片若不在云端,该组视频绝不删。
6.3 日期系统性错误(三批)
有几天照片的日期明显不对。修正思路不是"改个数字",而是找证据链反推真实日期:
| 批次 | 原显示 | 修正为 | 推断依据 |
|---|---|---|---|
| 83+88 张 | 2008-08-16/17/18 | 2011-06-04/05/06(+1022 天) | 相册名写着"周日在上海市",平移必须保持星期不变,2011-06-05 正是周日 |
| 344 张 | 2010-05-06/07 | 2010-09-22/23(+139 天) | 穿着、场景与前后照片序列比对 |
| 83 张 | 2010-08-13 | 2010-12-24(+133 天) | 照片里拍到期末"考试通知"白板(1.05 周三 / 1.10 周一 / 1.12 周三,正是 2011 年 1 月)、背景挂圣诞装饰、冬装、相册名"周五" → 2010-12-24 是周五 |
修完再重传、再删旧副本,全部零失败。EXIF 修改前整机备份 1,254 个文件,并生成 exif_rollback.tsv(含旧 fsid),随时可回滚。
经验:给照片"改日期"是高风险操作,必须先有独立证据(画面内容、星期、穿着、前后序列),而不是凭感觉调。
6.4 600 个打不开的视频:moov 头损坏
用户反馈"2017 年不少 10 秒视频打不开"。扫描全部 mp4/mov 后发现:600 个文件的 moov box 里,第一个子 box 本该是 mvhd(6d 76 68 64),实际被写成了时间戳数字(如 d5dcb7b1)。播放器和服务端都认不出 mvhd,于是读不到时长、读不到时间 → 无法播放。
视频/音频数据其实完好(H.264 + AAC,trak 完整),只是文件头坏了。
修复办法:重写 mvhd 头 28 字节。
|
|
timescale/duration沿用原文件的值(时长本来就是对的,绝不改动);creation/modification取 Google json 的photoTakenTime(保证云端时间不变);- 修改前整机字节备份到
_mvhd_fix_backup/。
修完 md5 变了 → 重传 → 服务端重新入库 → 可播放。最后删除云端 600 条旧坏记录。
这里有一个值得记住的调试细节:我第一版扫描脚本取子 box 类型时用了 raw[8:12](那其实是 size 字段),导致 11,351 个文件全部误报为损坏。修正为 raw[12:16] 后才得到真实的 600。改二进制解析后必须先用已知样本验证——正常文件是好的、坏文件是坏的,两头都要能识别。
七、删除:比上传更需要敬畏
上传错了可以重来,删除错了就真没了。所以删除环节设了三道闸:
1. 定位靠 md5,不靠文件名
云端有大量重名文件(前面提到 6 个 IMG_0574)。所有待删目标都先导出 cloud_items.json(含 fsid / md5 / path / shoot_time),再按 md5 精确定位 fsid。
2. 三重安全判据(缺一不可)
|
|
3. dry-run → 试点 → 全量
先打印待删清单给人看;再 --limit 3 --commit 试删 3 个,用只读探测接口确认(存在的 fsid 返回 errno=0,已删的返回 50504),并且必须配对照组(拿一个确定存在的 fsid 一起查),这样才能区分"真删掉了"和"接口报错";确认零误伤后才全量执行。
|
|
八、验收:不能靠"看起来对"
踩过两次验收口径的坑,值得单独说。
坑一:统计口径错误 第一次验收报告"照片丢了 9 张",查下来这 9 张在操作之前就不在云端(用户自己删过)。原因是判据顺序把"视频不在 → continue"写在了前面,导致图片视频双缺的组只被计一次。
正确做法:分母必须是 base 快照 ∩ 保留集,也就是"操作前确实存在、且本次应该保留"的集合。多出来的、少的,都相对这个集合来算。
坑二:日志不等于事实 有一批 1,299 个文件是用户在网页端传的,上传日志里没有任何记录,按日志统计会误报成"全部未传"。所以判断"是否传完"必须查云端,不能查自己的日志。
几个好用的验证手段:
- 秒级查云端时间:对文件跑一次
precreate,返回值里的data.shoot_time就是云端当前记录的时间。比全库翻页快几个数量级。 - 字节级验证:下载云端条目的文件头(Range 请求前 512KB),直接解析
moov判断能否播放。这是最硬的证据——不依赖"能不能打开"这种主观判断。 - 分页上限:早期一次全量核对误报 1,836 个"未找到",原因是拉取云端清单时
max_pages设得太小,根本没翻完。分页工具一定要确认翻到了底。
最终验收结果:
|
|
九、复盘
从 10 月 22 日到 10 月 29 日,最终结果:
- 36,084 个文件全部进入云端,时间错位清零;
- 四轮实况去重释放约 21.8 GB,零误伤;
- 三批日期修正全部落地,重传与删旧零失败;
- 600 个损坏视频全部修复可播放。
方法论上最值得留下的几条:
- 先分类再动手——把 36,000 个未知变成几类已知问题。
- 断点续传是一切的前提——有了它,限流、断网、崩溃都只是"重跑一次"。
- 不要用文件名做判据,要用内容指纹(md5 / 尺寸 / 时长)。
- 删除前先证明"新的一定在",再动手删旧的;删之前先试 3 个。
- 验收要用独立证据:字节、md5、云端快照,而不是"我看了一下好像没问题"。
- 改二进制前先备份,改完先用已知样本验证解析代码。
- 用户/领域知识不可替代——“2010-12-24 是周五"“这张照片里的白板写着考试通知”,这类信息只有人能提供,脚本再聪明也推不出来。
最后一点感受:这类任务真正的难点从来不是"调用某个接口”,而是在不可逆、无官方文档、数据量大的约束下,把每一步都做成可验证、可回滚的。工具会失效,接口会变,这套方法论不会。
文章作者
上次更新 2026-10-30