QQ空间导出照片到NAS
2025 年 07 月 18 日 • 开发
详细介绍了一个自动化工具,用于修复从QQ空间导出的照片中的时间信息丢失问题。
背景:为什么需要这个工具?
最近家里买了一台NAS(网络存储设备),用来备份和管理家庭照片。老婆想把QQ空间里的老照片全部下载下来,存到NAS里,方便以后整理和回忆。然而,从QQ空间导出的照片有一个很大的问题:所有照片的“修改时间”都变成了下载时间,而不是照片实际的拍摄时间。
这样一来,在NAS的相册里,所有照片都挤在“今天”这个时间点,完全失去了时间顺序。对于家庭照片来说,时间信息非常重要——我们希望能按照年份、月份来浏览照片,而不是看到一堆“2025年7月18日”的照片混在一起。
问题分析:为什么照片时间信息丢失了?
- QQ空间导出的照片没有EXIF信息
- EXIF(Exchangeable Image File Format)是存储在照片文件里的元数据,包含拍摄时间、相机型号、GPS位置等信息。
- 但QQ空间在导出照片时,可能出于隐私或压缩考虑,去掉了这些信息。
- 文件名里其实隐藏了时间信息,虽然照片的EXIF没了,但QQ空间导出的文件名通常是这样的格式:
IMG_20170815_123456.jpg
QQ图片20170815123456.png
- 其中
20170815
就是拍摄日期(2017年8月15日)。
- NAS的相册依赖“文件修改时间”排序
- 大多数NAS的相册默认会按照文件的“最后修改时间”来排序照片。
- 如果所有照片的修改时间都是下载时间,那它们就会全部挤在一起,无法按实际拍摄时间分类。
解决方案:用代码恢复照片的正确时间
既然文件名里包含时间信息,我们可以写一个工具,自动解析文件名中的日期,并修改文件的“最后修改时间”,让NAS能正确排序。
代码的核心功能
- 分类处理图片和视频
- 支持
.jpg
、.png
、.mp4
等常见格式。
- 支持
- 优先读取EXIF信息(如果有)
- 如果照片本身有EXIF时间,就直接使用,不修改。
- 从文件名解析日期
- 如果文件名包含
_yyyyMMdd_
或类似格式,就提取日期,并修改文件时间。
- 如果文件名包含
- 处理文件名冲突
- 避免同名文件覆盖,自动添加
(1)
、(2)
等后缀。
- 避免同名文件覆盖,自动添加
- 异常文件单独存放
- 文件名不符合格式的照片,会被复制到
error
目录,方便手动检查。
- 文件名不符合格式的照片,会被复制到
代码示例(Java)
xml
复制代码
<dependency>
<groupId>com.drewnoakes</groupId>
<artifactId>metadata-extractor</artifactId>
<version>2.18.0</version>
</dependency>
java
复制代码
import com.drew.imaging.ImageMetadataReader;
import com.drew.metadata.Metadata;
import com.drew.metadata.exif.ExifSubIFDDirectory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.FileTime;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
public class Main {
private static final String[] IMAGE_EXT = {".jpg", ".jpeg", ".png"};
private static final String[] VIDEO_EXT = {".mp4", ".mov", ".avi"};
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
public static void main(String[] args) throws IOException {
String sourceDir = ""; // 源目录
String targetImageDir = "";
String targetVideoDir = "";
String errorDir = ""; //
processMedia(sourceDir, targetImageDir, targetVideoDir, errorDir);
}
public static void processMedia(String srcPath, String imageOut, String videoOut, String errorOut) throws IOException {
Files.createDirectories(Paths.get(imageOut));
Files.createDirectories(Paths.get(videoOut));
Files.createDirectories(Paths.get(errorOut));
Files.walk(Paths.get(srcPath))
.filter(Files::isRegularFile)
.forEach(path -> {
String fileName = path.getFileName().toString();
String lower = fileName.toLowerCase();
try {
if (isImage(lower)) {
processImage(path, imageOut, errorOut);
} else if (isVideo(lower)) {
processVideo(path, videoOut, errorOut);
}
} catch (Exception e) {
System.err.println("处理失败:" + path + " => " + e.getMessage());
copyToErrorDir(path, errorOut); // 捕获全局异常,复制到错误目录
}
});
}
private static boolean isImage(String name) {
for (String ext : IMAGE_EXT) {
if (name.endsWith(ext)) return true;
}
return false;
}
private static boolean isVideo(String name) {
for (String ext : VIDEO_EXT) {
if (name.endsWith(ext)) return true;
}
return false;
}
private static void processImage(Path src, String outputDir, String errorDir) throws IOException {
String fileName = src.getFileName().toString();
Date exifDate = extractExifDate(src);
// 如果 EXIF 存在,直接复制到目标目录
if (exifDate != null) {
Path target = generateUniquePath(Paths.get(outputDir), fileName);
Files.copy(src, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("已跳过有 EXIF 的图片: " + fileName);
return;
}
// 无 EXIF 的图片,尝试解析文件名中的日期
String[] parts = fileName.split("_");
if (parts.length < 2) {
System.err.println("文件名格式不符合要求: " + fileName);
copyToErrorDir(src, errorDir); // 复制到错误目录
return;
}
try {
Date date = DATE_FORMAT.parse(parts[1]); // 解析如 "2017-09-28"
long timestamp = date.getTime();
Path target = generateUniquePath(Paths.get(outputDir), fileName);
Files.copy(src, target, StandardCopyOption.REPLACE_EXISTING);
FileTime time = FileTime.fromMillis(timestamp);
Files.setLastModifiedTime(target, time);
} catch (ParseException e) {
System.err.println("文件名日期解析失败: " + fileName);
copyToErrorDir(src, errorDir); // 复制到错误目录
}
}
private static void processVideo(Path src, String outputDir, String errorDir) throws IOException {
String fileName = src.getFileName().toString();
String[] parts = fileName.split("_");
if (parts.length < 2) {
System.err.println("文件名格式不符合要求: " + fileName);
copyToErrorDir(src, errorDir); // 复制到错误目录
return;
}
try {
Date date = DATE_FORMAT.parse(parts[1]);
long timestamp = date.getTime();
Path target = generateUniquePath(Paths.get(outputDir), fileName);
Files.copy(src, target, StandardCopyOption.REPLACE_EXISTING);
FileTime time = FileTime.fromMillis(timestamp);
Files.setLastModifiedTime(target, time);
} catch (ParseException e) {
System.err.println("文件名日期解析失败: " + fileName);
copyToErrorDir(src, errorDir); // 复制到错误目录
}
}
private static Date extractExifDate(Path imagePath) {
try {
Metadata metadata = ImageMetadataReader.readMetadata(imagePath.toFile());
ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
if (directory != null) {
return directory.getDateOriginal();
}
} catch (Exception e) {
System.err.println("读取 EXIF 失败: " + imagePath.getFileName() + " => " + e.getMessage());
}
return null;
}
/**
* 生成唯一的文件名,避免冲突
*/
private static Path generateUniquePath(Path dir, String fileName) {
Path target = dir.resolve(fileName);
if (!Files.exists(target)) {
return target;
}
// 如果文件已存在,在文件名后添加序号 (1), (2), ...
String baseName = fileName.substring(0, fileName.lastIndexOf('.'));
String extension = fileName.substring(fileName.lastIndexOf('.'));
int counter = 1;
while (true) {
String newName = baseName + " (" + counter + ")" + extension;
Path newTarget = dir.resolve(newName);
if (!Files.exists(newTarget)) {
return newTarget;
}
counter++;
}
}
/**
* 复制文件到错误目录
*/
private static void copyToErrorDir(Path src, String errorDir) {
try {
Path errorPath = Paths.get(errorDir);
Files.createDirectories(errorPath); // 确保目录存在
Path target = generateUniquePath(errorPath, src.getFileName().toString());
Files.copy(src, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("已复制异常文件到错误目录: " + src.getFileName());
} catch (IOException e) {
System.err.println("复制到错误目录失败: " + src + " => " + e.getMessage());
}
}
}
最终效果
运行这个工具后:
- 照片和视频会按实际拍摄时间排序,在NAS里显示正确的年份和月份。
- 文件名不符合格式的图片会被放到
error
目录,方便手动处理。 - 有EXIF的照片不受影响,避免重复处理。
现在,老婆可以在NAS里按照时间轴浏览照片了,再也不用面对一堆“今天”的照片了!🎉
总结
这个工具解决了从QQ空间导出照片后时间信息丢失的问题,让家庭照片在NAS里能正确按时间排序。如果你也遇到类似问题,可以试试这个方案,或者自己写一个类似的脚本。
留言 (0)