背景:为什么需要这个工具?

最近家里买了一台NAS(网络存储设备),用来备份和管理家庭照片。老婆想把QQ空间里的老照片全部下载下来,存到NAS里,方便以后整理和回忆。然而,从QQ空间导出的照片有一个很大的问题:所有照片的“修改时间”都变成了下载时间,而不是照片实际的拍摄时间。

这样一来,在NAS的相册里,所有照片都挤在“今天”这个时间点,完全失去了时间顺序。对于家庭照片来说,时间信息非常重要——我们希望能按照年份、月份来浏览照片,而不是看到一堆“2025年7月18日”的照片混在一起。

问题分析:为什么照片时间信息丢失了?

  1. QQ空间导出的照片没有EXIF信息
    • EXIF(Exchangeable Image File Format)是存储在照片文件里的元数据,包含拍摄时间、相机型号、GPS位置等信息。
    • 但QQ空间在导出照片时,可能出于隐私或压缩考虑,去掉了这些信息。
  2. 文件名里其实隐藏了时间信息,虽然照片的EXIF没了,但QQ空间导出的文件名通常是这样的格式:
    • IMG_20170815_123456.jpg
    • QQ图片20170815123456.png
    • 其中 20170815 就是拍摄日期(2017年8月15日)。
  3. NAS的相册依赖“文件修改时间”排序
    • 大多数NAS的相册默认会按照文件的“最后修改时间”来排序照片。
    • 如果所有照片的修改时间都是下载时间,那它们就会全部挤在一起,无法按实际拍摄时间分类。

解决方案:用代码恢复照片的正确时间

既然文件名里包含时间信息,我们可以写一个工具,自动解析文件名中的日期,并修改文件的“最后修改时间”,让NAS能正确排序。

代码的核心功能

  1. 分类处理图片和视频
    • 支持 .jpg.png.mp4 等常见格式。
  2. 优先读取EXIF信息(如果有)
    • 如果照片本身有EXIF时间,就直接使用,不修改。
  3. 从文件名解析日期
    • 如果文件名包含 _yyyyMMdd_ 或类似格式,就提取日期,并修改文件时间。
  4. 处理文件名冲突
    • 避免同名文件覆盖,自动添加 (1)(2) 等后缀。
  5. 异常文件单独存放
    • 文件名不符合格式的照片,会被复制到 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里能正确按时间排序。如果你也遇到类似问题,可以试试这个方案,或者自己写一个类似的脚本。