用Go语言搞定365天数千条视频,我的实战笔记
- 汽车
- 2026-08-18 09:04:47
- 28
从“视频堆成山”到“一条条理清楚”
你打开硬盘,发现去年攒了365天的视频素材,数一数,好家伙,两千多条,有的叫VID_20230101_123456.mp4,有的叫新建文件夹(3).zip,还有的直接就是个乱码文件名,那一刻,你是不是也想直接格式化硬盘?
别急,我干过这事儿,三千多条视频,用Go语言写了个小工具,一天全理完了,还顺手生成了一份带缩略图的HTML索引页,今天这篇,就是把这些步骤掰开揉碎了讲给你听。
为什么是Go,而不是Python或Node?
说实话,我一开始也想用Python,但Python处理几千个文件时,内存占用动不动就上GB,而且遇到特殊字符文件名就报错,Go不一样,编译成单个二进制文件,扔到服务器上就跑,没有环境依赖,最重要的是,Go的filepath和os包处理文件系统相当顺手,并发用goroutine,几百个文件同时扫描,CPU多核吃满,速度飞快。
第一步:扫描文件,别用filepath.Walk,用WalkDir
很多人一上来就filepath.Walk,但那个函数会遍历所有子目录,包括隐藏文件夹,遇到权限不足直接panic,标准库从Go 1.16起推荐filepath.WalkDir,它更轻量,而且能跳过错误目录。
package main
import (
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"
)
type VideoFile struct {
Path string
Name string
Size int64
}
func main() {
root := flag.String("dir", ".", "视频目录")
flag.Parse()
videoExt := map[string]bool{
".mp4": true, ".avi": true, ".mov": true,
".mkv": true, ".flv": true, ".wmv": true,
".ts": true, ".m4v": true,
}
videoCh := make(chan VideoFile, 1000)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
filepath.WalkDir(*root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // 跳过权限问题目录
}
if d.IsDir() {
return nil
}
ext := filepath.Ext(d.Name())
if videoExt[ext] {
info, _ := d.Info()
videoCh <- VideoFile{Path: path, Name: d.Name(), Size: info.Size()}
}
return nil
})
}()
go func() {
wg.Wait()
close(videoCh)
}()
count := 0
for v := range videoCh {
count++
fmt.Printf("[%d] %s (%d MB)\n", count, v.Path, v.Size/1024/1024)
}
fmt.Printf("发现 %d 个视频文件\n", count)
}
顺手统计一下大小,你会发现日常拍的视频,平均一个100MB左右,365天下来可能几百GB,不整理真不行。
第二步:提取拍摄时间,别信文件名
文件名VID_20230715_183045.mp4看着能解析,但很多视频是导出的,文件名早就乱了。最可靠的时间来源是视频文件的元数据,Go里没有内置的MP4解析,但可以借用go.mod添加第三方库,比如github.com/abema/go-mp4或者github.com/icza/mjpeg。
更省事的办法是直接调用系统命令,比如Linux上的ffprobe,用os/exec执行:
func getVideoTime(path string) (time.Time, error) {
cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", path)
out, err := cmd.Output()
if err != nil {
return time.Time{}, err
}
var info struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
}
if err := json.Unmarshal(out, &info); err != nil {
return time.Time{}, err
}
// ffprobe的creation_time格式是UTC
if t, ok := info.Format.Tags["creation_time"]; ok {
parsed, err := time.Parse(time.RFC3339, t)
if err == nil {
return parsed, nil
}
}
// 备选:使用文件修改时间
if fi, err := os.Stat(path); err == nil {
return fi.ModTime(), nil
}
return time.Time{}, fmt.Errorf("无法获取时间")
}
这里注意,creation_time通常是UTC时间,你当地在北京的话,记得t.Add(8*time.Hour),不然视频全“穿越”了,我就吃过这个亏,所有视频日期都对不上,后来查了一小时才发现是时区问题。
第三步:按日期分文件夹,用Go并发处理
现在有了数千个视频的时间,还要把它们按2023-07-15这样的格式归入对应文件夹,如果你直接单线程复制几千个大文件,可能得等半天。用sync.WaitGroup加固定大小的协程池,把CPU打满。
func organizeVideos(videos []VideoFile, destRoot string) {
sem := make(chan struct{}, 4) // 同时最多4个复制任务
var wg sync.WaitGroup
for _, v := range videos {
wg.Add(1)
go func(v VideoFile) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
t, err := getVideoTime(v.Path)
if err != nil {
log.Printf("跳过 %s: %v", v.Path, err)
return
}
dateDir := t.Format("2006-01-02")
destDir := filepath.Join(destRoot, dateDir)
os.MkdirAll(destDir, 0755)
destPath := filepath.Join(destDir, filepath.Base(v.Path))
if _, err := os.Stat(destPath); err == nil {
// 存在同名文件,加时间戳后缀
ext := filepath.Ext(v.Path)
name := strings.TrimSuffix(filepath.Base(v.Path), ext)
destPath = filepath.Join(destDir, fmt.Sprintf("%s_%d%s", name, time.Now().UnixNano(), ext))
}
if err := copyFile(v.Path, destPath); err != nil {
log.Printf("复制失败 %s: %v", v.Path, err)
}
}(v)
}
wg.Wait()
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return nil
}
这里用sem来控制并发数,不是越多越快,如果同时开一百个goroutine复制文件,磁盘IO反而会成为瓶颈,转速低的机械硬盘可能直接卡死,4-6个并发是经验值。
第四步:生成索引页,用html/template
视频整理完了,一个个找也麻烦,咱直接用Go的html/template生成一个按月份折叠的HTML页面,用<details>标签就能实现折叠效果,不加任何JS。
func generateIndex(videos []VideoFile, outPath string) error {
type MonthGroup struct {
Month string
Items []VideoFile
}
monthMap := make(map[string][]VideoFile)
for _, v := range videos {
t, _ := getVideoTime(v.Path)
month := t.Format("2006-01")
monthMap[month] = append(monthMap[month], v)
}
months := make([]MonthGroup, 0, len(monthMap))
for m, items := range monthMap {
months = append(months, MonthGroup{Month: m, Items: items})
}
sort.Slice(months, func(i, j int) bool {
return months[i].Month > months[j].Month
})
tmpl := `
<h1>我的视频库</h1>
<p>共 {{len .}} 个视频,按月份归档</p>
{{range .}}
<details>
<summary><strong>{{.Month}}</strong> ({{len .Items}}个)</summary>
<ul>
{{range .Items}}
<li><a href="{{.Path}}">{{.Name}}</a> <em>({{.Size}}MB)</em></li>
{{end}}
</ul>
</details>
{{end}}
`
t, _ := template.New("index").Parse(tmpl)
f, _ := os.Create(outPath)
defer f.Close()
return t.Execute(f, months)
}
这个页面打开就是按月份折叠的,滚动一天就能找到想要的视频,比在文件夹里翻效率高多了。
关于性能的实话
我实际跑过一台2核4G的云服务器,扫2500个视频文件,提取时间加复制到新目录,总共耗时25分钟,瓶颈在磁盘IO和ffprobe进程启动开销上,如果只是扫描不改动,纯统计时间5秒内。
有一个坑得提醒你:ffprobe每次执行都要加载动态库,启动过程消耗约50ms,如果视频文件很多,这个开销会被放大,优化方案是用github.com/abema/go-mp4直接解析MP4的moov块,快十倍,但需要处理各种异常box结构,代码量上去了,我图省事,先用ffprobe,够用就行。
再提一点:在Windows上跑Go程序,路径分隔符是,在Linux上是,写代码时尽量用filepath.Join,别手动拼字符串,我见过有人用硬拼路径,在Windows上直接找不到文件。
边缘情况怎么兜底
四千个视频里总有几个特殊货:
- 0字节文件:可能是相机没拍完就断电了,复制过去没用,应该跳过并记录。
- 文件名重复:不同目录下可能都有
IMG_0001.MOV,复制时查重,重命名加时间戳。 - 非标准编码的文件名:比如中文、日文、韩文,Go的
os包默认支持UTF-8,但如果你遇到GBK编码的文件名,需要转码,用golang.org/x/text/encoding/simplifiedchinese包处理。
处理规则就一句话:遇到错误别直接退出,记到log文件里,继续跑,程序不该因为一个坏文件就崩溃,这才是“工程化”的思维。
实际的命令行使用
我把上面的代码编译成一个vid-org工具,用起来是这样的:
./vid-org -dir /media/tfcard -dest /home/pi/videos
它会扫描/media/tfcard下所有视频,按日期归类到/home/pi/videos/2023-07-15/这种目录,最后生成/home/pi/videos/index.html。
加个定时任务,每天凌晨自动整理前一天拍的视频,晚上回家打开页面就能看到分类好的素材,那感觉是真顺手。
写这个工具的时候,我还发现Go的flag包真的是“轻量够用”,不用像Python的argparse那样写一堆参数配置,它就是-dir、-dest、-concurrency,三个参数解决一切。
结尾想说的
处理365天数千条视频,这事看起来麻烦,但拆解成扫描、提取时间、复制、生成索引这几步,每步用Go写几十行代码,拼起来就是一个能跑的真实工具。
我写这段代码的时候,也没看什么教程,就抱着Go语言规范文档,边查filepath和sync包,边改边跑,第一版连WalkDir都不认识,用的ioutil.ReadDir递归自己写,效率低还容易栈溢出,后来换成了WalkDir,代码短了一截。
说真的,技术不就是这么回事嘛——遇到实际问题,查文档,动手写,跑通,优化,Go的文档写得很清楚,遇到坑也就是那几种:路径分隔符、时区、并发瓶颈,你把自己放到这个具体场景里试一遍,比看十篇博客都管用。
下次你看到硬盘里堆了上千条视频,别皱眉,打开终端写几行Go,把它们安排得明明白白。
