提示

返回博客列表

下载了 1000 个视频之后,我花了三天三夜清理重复文件——聊聊视频去重与文件管理

下载了 1000 个视频之后,我花了三天三夜清理重复文件——聊聊视频去重与文件管理

当你只有十几个视频的时候,手动管理毫无压力。但当你用下载器囤了上千个视频——有从 B 站下的教程、从 YouTube 扒的技术演讲、从抖音快手存的短视频——文件夹就变成了灾难现场。同一个视频下载了两遍(文件名不一样)、不同清晰度的同一内容占了五份、缩略图和字幕散落一地、分不清哪个是"最终版"……这篇文章是我在管理一个 2TB 视频库时踩过的坑和总结出来的一套自动化方案:感知哈希去重、元数据索引、自动分类归档。

TL;DR:视频去重的核心不是比文件名,而是比内容。感知哈希(pHash)能识别同一视频的不同分辨率/码率版本,FFmpeg 提取关键帧后计算哈希值比对。文件管理方面,用 yt-dlp 的输出模板统一命名,按平台/频道/日期自动归档,写一个定时脚本把缩略图和字幕归位。再配合一个 SQLite 索引数据库,搜索和查重都在毫秒级完成。

目录

一、视频文件为什么这么难管

和照片、文档相比,视频文件的管理有几个天然的痛点:

第一,体积大,复制成本高。 一个 1080P 视频几百 MB,4K 视频几个 GB。你不能像处理文本文件那样"先复制一份再比对",每次复制都要等很久,磁盘也扛不住。

第二,格式和编码组合太多。 同一个视频可能同时有 H.264 版本、VP9 版本、AV1 版本,分辨率从 360P 到 4K,帧率 30fps 和 60fps——它们内容完全一样,但文件哈希值天差地别。

第三,附带文件多。 下载一个 YouTube 视频可能同时得到 .mp4.webm.srt.vtt.jpg 五六个文件,散落在同一个文件夹里,时间久了根本分不清哪个对应哪个。

第四,命名不一致。 同一个视频在不同平台上的标题可能不一样,加上各种特殊字符、空格、emoji,文件名排序完全是乱的。

这些问题的本质是:文件系统只能按"文件名"和"修改时间"来组织文件,但视频管理的需求是按"内容"来组织。

二、感知哈希去重:比内容不比文件名

2.1 为什么 MD5 不够用

文件哈希(MD5/SHA256)是最简单的去重方式——两个文件哈希值一样,说明内容完全一致。但视频场景下几乎没用:

同一个视频的两个版本:
  video_1080p_h264.mp4   MD5: a1b2c3d4...  ← 完全不同
  video_720p_vp9.webm    MD5: e5f6g7h8...  ← 完全不同

→ MD5 判断:不是重复(错误!)
→ 实际:同一内容,不同编码

我们需要的是感知哈希(Perceptual Hash)——不关心文件二进制内容,只关心"看起来是不是同一个画面"。

2.2 pHash 的原理

1. 从视频中提取若干关键帧(比如每 10 秒取一帧)
2. 对每帧计算 pHash 值(一个 64 位的指纹)
3. 比较两个视频的帧指纹序列的相似度
4. 如果相似度超过阈值(比如 90%),判定为重复

pHash 的核心是离散余弦变换(DCT)——把图像从空间域变换到频率域,取低频分量作为"视觉特征"。两张图看起来越像,它们的 pHash 汉明距离越小。

2.3 实现代码

import subprocess
import os
import json
from pathlib import Path
from collections import defaultdict

try:
    from PIL import Image
    import imagehash
except ImportError:
    print("请安装: pip install Pillow imagehash")


def extract_keyframes(video_path, output_dir, interval=10):
    """从视频中每隔 interval 秒提取一帧"""
    os.makedirs(output_dir, exist_ok=True)
    cmd = [
        'ffmpeg', '-i', video_path,
        '-vf', f'fps=1/{interval}',
        '-q:v', '2',
        f'{output_dir}/frame_%04d.jpg',
        '-y'
    ]
    subprocess.run(cmd, capture_output=True, check=True)


def compute_video_phash(video_path, num_samples=8):
    """计算视频的感知哈希序列"""
    import tempfile

    with tempfile.TemporaryDirectory() as tmpdir:
        # 提取采样帧
        # 均匀采样 num_samples 帧
        duration = get_video_duration(video_path)
        if duration <= 0:
            return None

        interval = max(1, duration / num_samples)

        for i in range(num_samples):
            timestamp = i * interval
            output = os.path.join(tmpdir, f'frame_{i:02d}.jpg')
            cmd = [
                'ffmpeg', '-ss', str(timestamp),
                '-i', video_path,
                '-vframes', '1',
                '-q:v', '2',
                output, '-y'
            ]
            subprocess.run(cmd, capture_output=True, check=True)

        # 计算每帧的 pHash
        hashes = []
        for i in range(num_samples):
            frame_path = os.path.join(tmpdir, f'frame_{i:02d}.jpg')
            if os.path.exists(frame_path):
                img = Image.open(frame_path)
                h = imagehash.phash(img)
                hashes.append(str(h))

        return hashes


def get_video_duration(video_path):
    """获取视频时长(秒)"""
    cmd = [
        'ffprobe', '-v', 'error',
        '-show_entries', 'format=duration',
        '-of', 'json', video_path
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    data = json.loads(result.stdout)
    return float(data.get('format', {}).get('duration', 0))


def hamming_distance(hash1, hash2):
    """计算两个哈希值的汉明距离(不同的位数)"""
    if len(hash1) != len(hash2):
        return len(hash1)
    return sum(c1 != c2 for c1, c2 in zip(hash1, hash2))


def find_duplicates(video_dir, threshold=0.85):
    """在目录中查找重复视频"""
    videos = list(Path(video_dir).glob('*.mp4')) + \
             list(Path(video_dir).glob('*.mkv')) + \
             list(Path(video_dir).glob('*.webm'))

    print(f"扫描到 {len(videos)} 个视频,正在计算指纹...")

    # 计算所有视频的指纹
    fingerprints = {}
    for i, v in enumerate(videos):
        print(f"\r处理中: {i+1}/{len(videos)} - {v.name[:50]}", end='')
        try:
            hashes = compute_video_phash(str(v))
            if hashes:
                fingerprints[str(v)] = hashes
        except Exception as e:
            print(f"\n跳过 {v.name}: {e}")

    print(f"\n完成 {len(fingerprints)} 个视频的指纹计算")

    # 两两比对
    paths = list(fingerprints.keys())
    duplicates = []

    for i in range(len(paths)):
        for j in range(i + 1, len(paths)):
            # 比较两帧序列的相似度
            hashes_a = fingerprints[paths[i]]
            hashes_b = fingerprints[paths[j]]

            # 取对应位置帧的最小汉明距离
            distances = []
            for ha, hb in zip(hashes_a, hashes_b):
                dist = hamming_distance(ha, hb)
                max_possible = len(ha)
                similarity = 1 - (dist / max_possible)
                distances.append(similarity)

            avg_similarity = sum(distances) / len(distances)

            if avg_similarity > threshold:
                size_a = os.path.getsize(paths[i])
                size_b = os.path.getsize(paths[j])
                duplicates.append({
                    'file1': paths[i],
                    'file2': paths[j],
                    'similarity': avg_similarity,
                    'size1': size_a,
                    'size2': size_b,
                    'keep': paths[i] if size_a >= size_b else paths[j],
                    'remove': paths[j] if size_a >= size_b else paths[i],
                })

    return duplicates


def auto_remove_duplicates(duplicates, dry_run=True):
    """自动清理重复视频(保留画质最好的版本)"""
    freed_space = 0

    for dup in duplicates:
        to_remove = dup['remove']
        size = os.path.getsize(to_remove)
        freed_space += size

        if dry_run:
            print(f"[预览] 删除: {Path(to_remove).name} "
                  f"({size/1024/1024:.1f} MB)")
            print(f"       保留: {Path(dup['keep']).name}")
            print(f"       相似度: {dup['similarity']:.1%}")
            print()
        else:
            os.remove(to_remove)
            print(f"[已删除] {Path(to_remove).name}")

    print(f"\n{'预计' if dry_run else '实际'}释放空间: "
          f"{freed_space/1024/1024/1024:.2f} GB")

    return freed_space

2.4 分级去重策略

不是所有"相似"都是"重复"。建议分三级处理:

级别 相似度 判定 处理方式
完全一致 > 98% 同一文件的不同副本 直接删除较小的
高度相似 90%-98% 同一视频不同清晰度/编码 保留画质最好的,删除其余
中度相似 70%-90% 可能是同一系列的不同集 标记为"待人工确认"
不相似 < 70% 不同视频 保留
def classify_duplicates(duplicates):
    """分级处理重复视频"""
    auto_delete = []
    manual_review = []

    for dup in duplicates:
        if dup['similarity'] > 0.98:
            auto_delete.append(dup)
        elif dup['similarity'] > 0.85:
            auto_delete.append(dup)  # 高相似度自动删,保留大的
        elif dup['similarity'] > 0.70:
            manual_review.append(dup)

    return auto_delete, manual_review

三、文件名规范:从混乱到有序

3.1 推荐的命名规范

{上传者} - {标题} [{分辨率}][{编码}].{扩展名}

示例:
  阮一峰 - ES6 入门教程 [1080P][H.264].mp4
  Fireship - 100 Seconds of Docker [4K][VP9].webm

3.2 yt-dlp 输出模板

# yt-dlp 的命名模板(推荐)
yt-dlp -o "%(uploader)s - %(title)s [%(resolution)s][%(vcodec)s].%(ext)s" URL

# 如果标题太长,截断
yt-dlp -o "%(uploader)s - %(title).100s [%(resolution)s].%(ext)s" URL

# 按频道分文件夹
yt-dlp -o "%(uploader)s/%(title)s.%(ext)s" URL

# 完整模板:日期 + 频道 + 标题
yt-dlp -o "%(upload_date)s - %(uploader)s - %(title)s.%(ext)s" URL

3.3 批量重命名工具

import re
import os
from pathlib import Path

def sanitize_filename(name):
    """清理文件名中的非法字符"""
    # 移除 Windows 不支持的字符
    illegal = r'[<>:"/\\|?*\x00-\x1f]'
    name = re.sub(illegal, '_', name)
    # 去除首尾空格和点
    name = name.strip('. ')
    # 限制长度(Windows 路径最长 260 字符)
    if len(name) > 200:
        name = name[:197] + '...'
    return name


def batch_rename(directory, pattern=None):
    """批量重命名视频文件"""
    video_exts = {'.mp4', '.mkv', '.webm', '.flv', '.avi', '.mov', '.ts'}

    for filepath in Path(directory).iterdir():
        if filepath.suffix.lower() not in video_exts:
            continue

        # 用 ffprobe 获取视频元数据
        try:
            info = get_video_metadata(str(filepath))
            new_name = f"{info.get('uploader', 'Unknown')} - " \
                       f"{info.get('title', filepath.stem)}"
            new_name = sanitize_filename(new_name)
            new_path = filepath.parent / f"{new_name}{filepath.suffix}"

            if new_path != filepath:
                print(f"重命名: {filepath.name}{new_path.name}")
                filepath.rename(new_path)
        except Exception as e:
            print(f"跳过 {filepath.name}: {e}")


def get_video_metadata(video_path):
    """读取视频文件的元数据标签"""
    cmd = [
        'ffprobe', '-v', 'error',
        '-show_entries', 'format_tags=title,artist,comment',
        '-of', 'json', video_path
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    data = json.loads(result.stdout)
    tags = data.get('format', {}).get('tags', {})
    return {
        'title': tags.get('title', ''),
        'uploader': tags.get('artist', ''),
        'comment': tags.get('comment', ''),
    }

四、自动分类归档:按平台/频道/日期整理

4.1 目录结构设计

~/Videos/
├── YouTube/
│   ├── Fireship/
│   │   ├── 2026-01/
│   │   │   └── Docker in 100 Seconds.mp4
│   │   └── 2026-02/
│   ├── 3Blue1Brown/
│   └── ...
├── Bilibili/
│   ├── 科技/
│   └── 教程/
├── 抖音/
├── 快手/
└── Unsorted/          ← 未分类的视频放这里

4.2 自动分类脚本

import shutil
from datetime import datetime

def auto_categorize(video_path, target_base='~/Videos'):
    """根据视频来源自动归档"""
    info = get_video_metadata(video_path)

    # 从元数据或文件名推断来源
    source = detect_source(video_path, info)

    uploader = info.get('uploader', 'Unknown')
    upload_date = info.get('upload_date', '')

    # 构建目标路径
    parts = [target_base, source]
    if uploader and uploader != 'Unknown':
        parts.append(sanitize_filename(uploader))

    if upload_date:
        year_month = f"{upload_date[:4]}-{upload_date[4:6]}"
        parts.append(year_month)

    target_dir = os.path.join(*parts)
    os.makedirs(target_dir, exist_ok=True)

    # 移动文件
    filename = os.path.basename(video_path)
    target_path = os.path.join(target_dir, filename)

    if not os.path.exists(target_path):
        shutil.move(video_path, target_path)
        print(f"归档: {filename}{target_dir}")
    else:
        print(f"跳过(目标已存在): {filename}")


def detect_source(video_path, metadata):
    """推断视频来源平台"""
    filename = os.path.basename(video_path).lower()

    # 检查文件名特征
    if 'bilibili' in filename or 'bv' in filename:
        return 'Bilibili'
    if 'youtube' in filename or '-yt-' in filename:
        return 'YouTube'
    if 'douyin' in filename or '抖音' in filename:
        return 'Douyin'
    if 'kuaishou' in filename or '快手' in filename:
        return 'Kuaishou'

    # 检查元数据中的 comment(yt-dlp 常在这里写来源 URL)
    comment = metadata.get('comment', '')
    if 'youtube.com' in comment:
        return 'YouTube'
    if 'bilibili.com' in comment:
        return 'Bilibili'

    return 'Unsorted'

五、构建视频索引数据库

当视频数量超过几百个,光靠文件系统就不够了。建一个 SQLite 索引库,搜索和查重都在毫秒级完成。

import sqlite3
import os
import json
from pathlib import Path

class VideoIndexDB:
    """视频索引数据库"""

    def __init__(self, db_path='video_index.db'):
        self.conn = sqlite3.connect(db_path)
        self.conn.row_factory = sqlite3.Row
        self._init_tables()

    def _init_tables(self):
        self.conn.executescript("""
            CREATE TABLE IF NOT EXISTS videos (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                path TEXT UNIQUE NOT NULL,
                filename TEXT NOT NULL,
                title TEXT,
                uploader TEXT,
                source TEXT,
                duration REAL,
                resolution TEXT,
                codec TEXT,
                filesize INTEGER,
                phash TEXT,
                thumbnail_path TEXT,
                subtitle_path TEXT,
                download_date TEXT,
                indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            );

            CREATE INDEX IF NOT EXISTS idx_title ON videos(title);
            CREATE INDEX IF NOT EXISTS idx_uploader ON videos(uploader);
            CREATE INDEX IF NOT EXISTS idx_source ON videos(source);
            CREATE INDEX IF NOT EXISTS idx_phash ON videos(phash);
        """)
        self.conn.commit()

    def index_video(self, video_path):
        """索引单个视频"""
        video_path = str(Path(video_path).resolve())

        # 检查是否已索引
        existing = self.conn.execute(
            "SELECT id FROM videos WHERE path = ?", (video_path,)
        ).fetchone()
        if existing:
            return existing['id']

        # 提取元数据
        info = get_video_metadata(video_path)
        duration = get_video_duration(video_path)
        filesize = os.path.getsize(video_path)
        phash = compute_video_phash(video_path)

        # 检测来源
        source = detect_source(video_path, info)

        cursor = self.conn.execute("""
            INSERT INTO videos
            (path, filename, title, uploader, source, duration, filesize, phash)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        """, (
            video_path,
            os.path.basename(video_path),
            info.get('title', ''),
            info.get('uploader', ''),
            source,
            duration,
            filesize,
            json.dumps(phash) if phash else None,
        ))
        self.conn.commit()
        return cursor.lastrowid

    def index_directory(self, directory):
        """批量索引目录"""
        video_exts = {'.mp4', '.mkv', '.webm', '.flv', '.avi', '.mov', '.ts'}
        videos = []

        for ext in video_exts:
            videos.extend(Path(directory).rglob(f'*{ext}'))

        print(f"发现 {len(videos)} 个视频文件")
        for i, v in enumerate(videos):
            print(f"\r索引: {i+1}/{len(videos)}", end='')
            try:
                self.index_video(str(v))
            except Exception as e:
                print(f"\n跳过 {v.name}: {e}")

        print(f"\n索引完成,共 {self.count()} 条记录")

    def search(self, keyword):
        """搜索视频"""
        query = f"%{keyword}%"
        return self.conn.execute("""
            SELECT * FROM videos
            WHERE title LIKE ? OR uploader LIKE ? OR filename LIKE ?
            ORDER BY title
        """, (query, query, query)).fetchall()

    def find_duplicates(self, threshold=0.85):
        """从数据库查找重复视频"""
        # 这里可以用 SQL 做初步筛选,再在 Python 中精确比对
        # 简化的实现:查所有有 phash 的视频,两两比对
        rows = self.conn.execute(
            "SELECT * FROM videos WHERE phash IS NOT NULL"
        ).fetchall()

        duplicates = []
        for i in range(len(rows)):
            for j in range(i + 1, len(rows)):
                h1 = json.loads(rows[i]['phash'])
                h2 = json.loads(rows[j]['phash'])
                sim = self._compare_hashes(h1, h2)
                if sim > threshold:
                    duplicates.append({
                        'video1': rows[i]['path'],
                        'video2': rows[j]['path'],
                        'similarity': sim,
                    })

        return duplicates

    def _compare_hashes(self, h1, h2):
        distances = []
        for a, b in zip(h1, h2):
            dist = sum(c1 != c2 for c1, c2 in zip(a, b))
            distances.append(1 - dist / len(a))
        return sum(distances) / len(distances) if distances else 0

    def count(self):
        return self.conn.execute("SELECT COUNT(*) FROM videos").fetchone()[0]

    def stats(self):
        """数据库统计"""
        total = self.count()
        total_size = self.conn.execute(
            "SELECT SUM(filesize) FROM videos"
        ).fetchone()[0] or 0

        sources = self.conn.execute("""
            SELECT source, COUNT(*) as cnt
            FROM videos GROUP BY source ORDER BY cnt DESC
        """).fetchall()

        return {
            'total': total,
            'total_size_gb': total_size / 1024 / 1024 / 1024,
            'by_source': {r['source']: r['cnt'] for r in sources},
        }

六、缩略图与字幕的归位

下载视频时,yt-dlp 和同类工具会生成一堆附属文件,散落在下载目录里。写个脚本把它们归位:

def organize_media_files(directory):
    """整理视频附属文件"""
    video_exts = {'.mp4', '.mkv', '.webm', '.flv', '.avi', '.mov'}
    thumb_exts = {'.jpg', '.jpeg', '.png', '.webp'}
    sub_exts = {'.srt', '.vtt', '.ass', '.ssa'}

    for filepath in Path(directory).iterdir():
        stem = filepath.stem
        ext = filepath.suffix.lower()

        if ext in video_exts:
            continue  # 跳过视频文件本身

        # 查找同名的视频文件
        video_file = None
        for v_ext in video_exts:
            candidate = filepath.parent / f"{stem}{v_ext}"
            if candidate.exists():
                video_file = candidate
                break

        if not video_file:
            continue

        # 创建附属文件夹
        if ext in thumb_exts:
            target_dir = filepath.parent / 'thumbnails'
        elif ext in sub_exts:
            target_dir = filepath.parent / 'subtitles'
        else:
            target_dir = filepath.parent / 'others'

        target_dir.mkdir(exist_ok=True)
        target = target_dir / filepath.name

        if not target.exists():
            shutil.move(str(filepath), str(target))
            print(f"整理: {filepath.name}{target_dir.name}/")

七、一个完整的视频库管理脚本

把上面的功能整合成一个命令行工具:

#!/usr/bin/env python3
"""
视频库管理工具
用法:
  python video_manager.py index ~/Videos       # 索引视频库
  python video_manager.py dedup ~/Videos       # 查找重复视频
  python video_manager.py organize ~/Videos    # 自动分类归档
  python video_manager.py stats                # 查看统计
  python video_manager.py search "Docker"      # 搜索视频
"""

import argparse
import sys


def main():
    parser = argparse.ArgumentParser(description='视频库管理工具')
    subparsers = parser.add_subparsers(dest='command')

    # index 命令
    p = subparsers.add_parser('index')
    p.add_argument('directory')

    # dedup 命令
    p = subparsers.add_parser('dedup')
    p.add_argument('directory')
    p.add_argument('--threshold', type=float, default=0.85)
    p.add_argument('--delete', action='store_true')

    # organize 命令
    p = subparsers.add_parser('organize')
    p.add_argument('directory')

    # stats 命令
    subparsers.add_parser('stats')

    # search 命令
    p = subparsers.add_parser('search')
    p.add_argument('keyword')

    args = parser.parse_args()

    if args.command == 'index':
        db = VideoIndexDB()
        db.index_directory(args.directory)
        s = db.stats()
        print(f"总计: {s['total']} 个视频, {s['total_size_gb']:.1f} GB")

    elif args.command == 'dedup':
        dups = find_duplicates(args.directory, args.threshold)
        if not dups:
            print("未发现重复视频")
        else:
            print(f"发现 {len(dups)} 组重复视频")
            auto_remove_duplicates(dups, dry_run=not args.delete)

    elif args.command == 'organize':
        for root, dirs, files in os.walk(args.directory):
            for f in files:
                if Path(f).suffix.lower() in {'.mp4', '.mkv', '.webm'}:
                    auto_categorize(os.path.join(root, f))

    elif args.command == 'stats':
        db = VideoIndexDB()
        s = db.stats()
        print(f"视频总数: {s['total']}")
        print(f"总大小: {s['total_size_gb']:.1f} GB")
        print(f"\n按来源分布:")
        for source, count in s['by_source'].items():
            print(f"  {source}: {count} 个")

    elif args.command == 'search':
        db = VideoIndexDB()
        results = db.search(args.keyword)
        print(f"搜索 '{args.keyword}': {len(results)} 个结果")
        for r in results:
            print(f"  [{r['source']}] {r['title']} - {r['filename']}")

if __name__ == '__main__':
    main()

八、合规与温馨提示

  • 本文讨论的文件管理技术适用于你合法拥有的视频文件
  • 自动去重时建议先用 dry-run 模式预览,确认无误后再实际删除
  • pHash 去重不是 100% 准确——两段不同的视频如果开头画面相似(比如都有黑屏片头),可能被误判。建议对"待人工确认"的项做二次审查
  • 更多法律讨论见 下载视频算侵权吗?聊聊个人备份与版权的那条线

管理视频库这件事,说到底就是一句话:不要依赖文件名来记忆内容,让机器帮你做索引和比对。 花一个下午把索引库和自动归档搭好,之后每次下载完视频跑一遍脚本,整个库就永远井井有条。

本文由 VidDown 技术博客原创发布。VidDown 下载器内置了智能命名和自动归档功能——根据视频来源自动生成规范的文件名,下载完成后自动归类到对应文件夹。访问 VidDown 了解更多。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部