字节笔记本

2026年2月22日

MomentsCleaner:群晖 Moments 重复文件清理工具

本文介绍 MomentsCleaner,一个专为群晖 Moments 设计的重复文件清理工具。该工具通过 MD5 哈希值扫描重复文件,自动保留文件名最短的版本,安全高效地释放存储空间。

项目简介

MomentsCleaner 是一个开源的 Go 语言项目,由开发者 0990 创建维护。该项目专门解决群晖 Moments 照片管理中常见的重复文件问题。

群晖 Moments 在同步过程中经常会在同一文件夹下生成重复文件(如 xxx_1.jpgxxx_2.jpg),占用大量存储空间。虽然群晖自带的存储空间分析器能够列出重复文件,但需要手动逐一判别删除,当重复文件数量庞大时操作非常繁琐。MomentsCleaner 正是为解决这一痛点而生。

核心特性

  • 智能扫描:递归扫描当前目录及所有子目录下的文件
  • MD5 去重:基于文件内容 MD5 哈希值精确识别重复文件
  • 安全删除:仅删除同目录下的重复文件,避免误删
  • 自动备份:被删除的文件自动移动到"被删除的文件"目录,便于复查
  • 保留策略:自动保留文件名最短的版本(通常是最原始的文件)
  • 详细日志:使用 logrus 记录完整的扫描和清理过程

技术栈

  • Go 语言:核心开发语言,支持跨平台编译
  • logrus:结构化日志库,提供清晰的执行记录
  • syscall:Windows 系统调用,支持检测隐藏文件
  • crypto/md5:标准库 MD5 哈希计算

安装指南

前置要求

  • Windows 系统(工具主要针对 Windows 环境设计)
  • 已安装 Synology Drive 客户端并同步 Moments 照片

下载安装

  1. 访问 GitHub Releases 下载最新版本
  2. MomentsCleaner.exe 放入 Moments 同步文件夹或子文件夹中

源码编译

bash
# 克隆仓库
git clone https://github.com/0990/momentscleaner.git
cd momentscleaner

# 编译(需要 Go 环境)
go build -o MomentsCleaner.exe

快速开始

使用步骤

  1. 同步照片:在 Windows 系统中使用 Synology Drive 将 Moments 照片同步到本地

  2. 执行清理:将 MomentsCleaner.exe 放入 Moments 文件夹或子文件夹,双击执行

  3. 检查结果:执行完成后会生成 被删除的文件 目录,所有被删除的文件都保存在这里

  4. 确认删除:检查 被删除的文件 目录,确认无误后手动删除

  5. 同步回群晖:清理完成后,开启 Drive 的双向同步(或单向上传),将更新同步到群晖

执行示例

bash
# 将可执行文件放入 Moments 目录
MomentsCleaner.exe

# 查看输出日志
type cleaner_info.log

代码分析

项目结构

text
momentscleaner/
├── cleaner/
│   ├── cleaner.go    # 核心清理逻辑
│   └── util.go       # 工具函数
├── logconfig/        # 日志配置
├── main.go           # 程序入口
├── go.mod
└── README.md

核心代码解析

main.go - 程序入口

go
package main

import (
    "github.com/0990/momentscleaner/cleaner"
    "github.com/0990/momentscleaner/logconfig"
)

func main() {
    logconfig.InitLogrus("cleaner", 10)
    cleaner.DoClean()
}

程序初始化日志配置后,调用 cleaner.DoClean() 开始清理流程。

cleaner.go - 核心清理逻辑

go
const BACKUP_DIR_NAME = "被删除的文件"

func DoClean() {
    t := time.Now()
    dirWalk("./")
    logrus.Infof("总扫描文件数:%d", allFileCount)
    logrus.Infof("总扫描文件夹数:%d", allDirCount)
    logrus.Infof("总重复文件被删除数:%d", allDelCount)
    logrus.Infof("总耗时%v", time.Since(t))
}

DoClean 函数是清理流程的入口,它调用 dirWalk 递归遍历目录,最后输出统计信息。

目录遍历与重复检测

go
func dirWalk(dirPath string) {
    // 跳过备份目录
    if strings.Contains(dirPath, BACKUP_DIR_NAME) {
        return
    }

    // 跳过隐藏文件/目录
    hidden, err := isFileHidden(dirPath)
    if hidden {
        return
    }

    // 读取目录内容
    fs, err := ioutil.ReadDir(dirPath)

    // 使用 map 存储 MD5 -> 文件列表
    hash2files := make(map[string][]os.FileInfo, 0)

    for _, file := range fs {
        if file.IsDir() {
            // 递归处理子目录
            dirWalk(dirPath + file.Name() + "/")
        } else {
            // 跳过 .log 文件
            if path.Ext(file.Name()) == ".log" {
                continue
            }

            // 计算 MD5
            md5, err := md5File(name)
            hash2files[md5] = append(hash2files[md5], file)
        }
    }

    // 处理重复文件
    for _, files := range hash2files {
        if len(files) < 2 {
            continue
        }

        // 保留名称最短的文件
        min := len(files[0].Name())
        for i := 1; i < len(files); i++ {
            if len(files[i].Name()) < min {
                min = len(files[i].Name())
            }
        }

        // 移动其他重复文件到备份目录
        for _, file := range files {
            if len(file.Name()) == min {
                log.WithField("filename", file.Name()).Info("保留")
                continue
            }
            // 移动到备份目录...
        }
    }
}

util.go - 工具函数

go
// 创建目录(如果不存在)
func createDirIfNoExist(path string) {
    _, err := os.Stat(path)
    if err != nil {
        if os.IsNotExist(err) {
            os.MkdirAll(path, os.ModePerm)
        }
    }
}

// 检测文件是否为隐藏文件(Windows)
func isFileHidden(path string) (bool, error) {
    name := utf16.Encode([]rune(path + "\x00"))
    attributes, err := syscall.GetFileAttributes((*uint16)(unsafe.Pointer(&name[0])))
    if err != nil {
        return false, err
    }
    return attributes&syscall.FILE_ATTRIBUTE_HIDDEN != 0, nil
}

// 计算文件 MD5
func md5File(filename string) (string, error) {
    file, err := os.Open(filename)
    defer file.Close()
    if err != nil {
        return "", err
    }
    md5h := md5.New()
    io.Copy(md5h, file)
    return fmt.Sprintf("%x", md5h.Sum([]byte(""))), nil
}

设计说明

为什么只删除同目录下的重复文件?

群晖 Moments 的重复文件问题主要集中在同一文件夹内(如同一张照片被多次导入生成 xxx_1.jpgxxx_2.jpg)。限制在同目录下处理:

  1. 安全性高:避免误删不同目录下可能需要的相同文件
  2. 针对性强:精准解决 Moments 的主要痛点
  3. 符合使用场景:大部分重复文件确实位于同一目录

删除策略:保留文件名最短的版本

当检测到重复文件时,工具保留文件名最短的文件,删除其他版本。这是因为:

  • 原始文件名通常最短(如 photo.jpg
  • 重复文件往往带有序号后缀(如 photo_1.jpgphoto_2.jpg
  • 此策略能最大程度保留原始文件

注意事项

  1. 备份重要数据:首次使用前建议备份重要照片
  2. 检查删除结果:清理后务必检查"被删除的文件"目录
  3. 日志文件:执行过程中会生成 cleaner_info.log 记录详细操作
  4. 存档状态:该项目已被作者存档(2025年8月),现为只读状态

项目统计

  • Stars: 41
  • Forks: 5
  • 主要语言: Go
  • 状态: 已存档(Archived)

项目链接

分享: