当前位置:首页 > 汽车 > 正文

用Go语言搞定365天数千条视频,我的实战笔记

  • 汽车
  • 2026-08-18 09:04:47
  • 51
摘要: 从“视频堆成山”到“一条条理清楚”你打开硬盘,发现去年攒了365天的视频素材,数一数,好家伙,两千多条,有的叫VID_20230...

从“视频堆成山”到“一条条理清楚”

你打开硬盘,发现去年攒了365天的视频素材,数一数,好家伙,两千多条,有的叫VID_20230101_123456.mp4,有的叫新建文件夹(3).zip,还有的直接就是个乱码文件名,那一刻,你是不是也想直接格式化硬盘?

别急,我干过这事儿,三千多条视频,用Go语言写了个小工具,一天全理完了,还顺手生成了一份带缩略图的HTML索引页,今天这篇,就是把这些步骤掰开揉碎了讲给你听。

为什么是Go,而不是Python或Node?

说实话,我一开始也想用Python,但Python处理几千个文件时,内存占用动不动就上GB,而且遇到特殊字符文件名就报错,Go不一样,编译成单个二进制文件,扔到服务器上就跑,没有环境依赖,最重要的是,Go的filepathos包处理文件系统相当顺手,并发用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语言规范文档,边查filepathsync包,边改边跑,第一版连WalkDir都不认识,用的ioutil.ReadDir递归自己写,效率低还容易栈溢出,后来换成了WalkDir,代码短了一截。

说真的,技术不就是这么回事嘛——遇到实际问题,查文档,动手写,跑通,优化,Go的文档写得很清楚,遇到坑也就是那几种:路径分隔符、时区、并发瓶颈,你把自己放到这个具体场景里试一遍,比看十篇博客都管用。

下次你看到硬盘里堆了上千条视频,别皱眉,打开终端写几行Go,把它们安排得明明白白。

用Go语言搞定365天数千条视频,我的实战笔记