利用Cloudflare Pages搭建媒体库实战

前段时间极客湾因为公开揭露手机厂商的龌龊行径——媒体特调机内幕,导致其视频被全网下架。随后极客湾开放视频转载权限,全网随即上演了补档大战,来表达对厂商捂嘴的不满。
我也是拿到了极客湾的视频原文件,看了一眼,好家伙,8.4GB,还是4K 60FPS的视频,这要分享还有点费流量。
在平常的一次去吃午饭的路上,我走在校园的道路上,突然灵光一闪:如果我能把这个视频放到Cloudflare Pages,岂不美哉?!
但是,我突然想起来了Cloudflare Pages免费版的限制:25MB单文件大小——这几乎无法放大型的视频。
不过,有一句诗说得好:“山穷水尽疑无路,柳暗花明又一村。”那既然限制文件大小,那我把视频文件切开,每一个块符合大小限制,不就行了吗?!
而免费版的Cloudflare Pages单个项目文件数量限制为20000个,也就是理论上可以放20000x25MB=5000000MB,也就是大约488GB——这已经比一个500G机械硬盘的实际可用空间465GB还大了!
最重要的是,Cloudflare并不限制项目数量,理论上对于个人来说约等于无限容量了!
说干就干,我随即开始了理论的验证。

验证想法可行性

通过查阅资料,我了解到现代的浏览器可以利用hls.js实现对切分后视频的播放,目前早就有了成熟的方案,且各种视频平台就在用。需要的视频形态是一个.m3u8的视频索引文件,外加一大堆视频切割的碎片.ts文件,而把普通的视频转成这样的工作,ffmpeg就能干!
这下不就把技术路线跑通了嘛,赶紧验证一下想法:

ffmpeg -hwaccel cuda -hwaccel_output_format cuda -i 零售机_游戏性能大横评_2026.mp4 \
  -c:v h264_nvenc -preset p6 -profile:v high -level:v 5.2 \
  -rc vbr -b:v 18M -maxrate 24M -bufsize 36M \
  -g 180 -keyint_min 180 -sc_threshold 0 \
  -c:a aac -b:a 192k \
  -hls_time 3 -hls_playlist_type vod -hls_segment_filename "chunk_%04d.ts" \
  video-0.m3u8

通过调整每个块的时间,确保每个块都不超过25MB,符合Cloudflare Pages的文件大小限制。
然后再写一个html用于播放:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>极客湾-手机2026游戏性能大横评(补档)4K 60FPS</title>
    <script src="./hls.js"></script>
    <style>
        /* 重置基础样式,干掉所有默认边距 */
        html, body {
            margin: 0;
            padding: 0;
            width: 100%;
            height: 100%;
            background-color: #000;
            overflow: hidden;
            /* 阻止移动端浏览器默认的下拉刷新、边缘滑动等行为 */
            overscroll-behavior: none;
        }

        .video-container {
            width: 100%;
            /* 【修复1】将 100vh 改为 100%,配合 body 的 100%,完美避开手机浏览器底部导航栏遮挡控制栏的问题 */
            height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
        }

        video {
            width: 100%;
            height: 100%;
            object-fit: contain;
            outline: none;
            /* 去除移动端点击视频时闪烁的高亮背景色 */
            -webkit-tap-highlight-color: transparent;
        }

        /* 状态文字悬浮在左上角 */
        .status {
            position: absolute;
            top: 20px;
            left: 20px;
            color: rgba(255, 255, 255, 0.6);
            font-family: sans-serif;
            font-size: 14px;
            z-index: 10;
            pointer-events: none;
            text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
        }
    </style>
</head>
<body>

    <div class="status" id="status-text">准备加载视频...</div>

    <div class="video-container">
        <video id="video-player" controls playsinline webkit-playsinline preload="auto"></video>
    </div>

    <script>
        const video = document.getElementById('video-player');
        const statusText = document.getElementById('status-text');

        // 指向你同目录下的 m3u8 文件
        const videoSrc = './video-data/video-0.m3u8';

        if (Hls.isSupported()) {
            const hls = new Hls({
                maxBufferLength: 30,
                maxMaxBufferLength: 60
            });

            hls.loadSource(videoSrc);
            hls.attachMedia(video);

            hls.on(Hls.Events.MANIFEST_PARSED, function() {
                statusText.innerText = "视频索引加载成功";
                setTimeout(() => {
                    statusText.style.display = 'none';
                }, 3000);
            });

            hls.on(Hls.Events.ERROR, function (event, data) {
                if (data.fatal) {
                    statusText.style.display = 'block';
                    switch (data.type) {
                        case Hls.ErrorTypes.NETWORK_ERROR:
                            statusText.innerText = "网络错误,尝试重连...";
                            hls.startLoad();
                            break;
                        case Hls.ErrorTypes.MEDIA_ERROR:
                            statusText.innerText = "媒体格式错误,尝试恢复...";
                            hls.recoverMediaError();
                            break;
                        default:
                            statusText.innerText = "发生致命错误。";
                            hls.destroy();
                            break;
                    }
                }
            });
        }
        else if (video.canPlayType('application/vnd.apple.mpegurl')) {
            video.src = videoSrc;
            video.addEventListener('loadedmetadata', function() {
                statusText.innerText = "原生 HLS 加载成功";
                setTimeout(() => statusText.style.display = 'none', 3000);
            });
        } else {
            statusText.innerText = "浏览器不支持 HLS";
        }
    </script>
</body>
</html>

把转化后的视频碎片放到html同目录下的video-data文件夹,本地启动Nginx设定好网站目录,浏览器打开,果然可以正常播放。这下就好玩了!
这里还有极客湾的测试视频,也是我方案验证阶段的效果,可以去看看:
极客湾-手机2026游戏性能大横评(补档)4K 60FPS

重要提醒

需要注意的是,执行下面的程序之前,需要把你的Cloudflare账户的ID和令牌导入到命令行环境中,例如:

export CLOUDFLARE_API_TOKEN="www.nekopara.uk"
export CLOUDFLARE_ACCOUNT_ID="www.nekopara.uk"

请替换为你自己的真实 Token 和 Account ID。
其中创建API Token时,正确的选择权限选项是:
账户 > Cloudflare Pages > 编辑
还有一个最重要的提醒,这些操作其实不是正常的Pages产品使用方式,存在账户封禁或限制使用等风险,请务必使用新创建的独立账号进行操作,以免造成不必要的损失!

尝试把成果上传Pages

但是,在这一步,我就踩坑了,还是踩了大坑!
正当我尝试把转化好的文件夹通过Cloudflare Pages的部署页面上传时,我遇到了大坑:上传大概进行半个小时后,网页疯狂弹出API 403报错。
一开始我还以为是网络的问题,开启了魔法网络环境,结果问题依旧。这时我意识到,上传的API应该是有临时Token超时机制的,当文件数量太多,体积太大,上传的时间变长了,就会触发这个问题。
似乎这一切无解了?我能做出来符合文件大小限制的静态网页目录,却无法完整上传。
但是,在看了Cloudflare的提示后,我发现了一个可能的路径:用wrangler进行部署——这个可以支持上传20000个文件的项目。而网页上传限制为1000个文件。
于是乎,我就安装了wrangler进行尝试:
因为wrangler需要nodejs环境,我们先安装nodejs:

pacman -S nodejs npm

然后在安装wrangler,系统级安装命令如下:

npm install -g wrangler

当然,也可以在当前目录进行安装,这个更推荐:

npx wrangler

安装完成后,我们来进行上传:

npx wrangler pages deploy ./page --project-name geekwan

因为网速比较慢,我就留着电脑上传,自己去吃饭了。结果回来一看还是报错403,那看来这个接口不但是网页会存在时间限制,用命令行工具也会。
难道说。。。真的无解了吗?
在LLM的帮助下,我快速实现了这个测试脚本:

import os
import shutil
import subprocess
import time
from pathlib import Path

# ================= 配置区域 =================
# 需要执行的特定 Bash 命令 (例如:处理当前目录下的所有文件)
# 注意:命令应该设计为处理当前源文件夹中的文件,或者你需要根据实际逻辑调整命令参数
BASH_COMMAND = "npx wrangler pages deploy ./page --project-name geekwan"

# 源文件夹路径
SOURCE_DIR = Path("/run/media/chocola/3T-DATA02/Projects/Pages/tmp")

# 目标文件夹路径 (如果不存在会自动创建)
TARGET_DIR = Path("/run/media/chocola/3T-DATA02/Projects/Pages/page/video-data")

# 每次循环移动的文件数量
FILES_PER_BATCH = 30

# 最大循环次数 (设置为 None 表示直到源文件夹为空为止)
MAX_LOOPS = None
# ===========================================

def run_bash_command(cmd):
    """执行 Bash 命令并返回是否成功"""
    print(f"\n[执行命令] {cmd}")
    try:
        # shell=True 允许运行带管道、重定向等的复杂 bash 命令
        result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True)
        print("[命令输出]:", result.stdout.strip())
        return True
    except subprocess.CalledProcessError as e:
        print(f"[命令失败]: {e}")
        if e.stderr:
            print("[错误信息]:", e.stderr.strip())
        return False

def move_files(src, dst, count):
    """从 src 移动 count 个文件到 dst"""
    # 获取源文件夹中的所有文件 (排除子文件夹,只处理文件)
    # listdir() 顺序不固定,如果需要特定顺序(如按时间),请使用 sorted(os.listdir(...), key=...)
    files = [f for f in src.iterdir() if f.is_file()]

    if not files:
        return 0

    # 限制移动数量
    files_to_move = files[:count]

    moved_count = 0
    for file_path in files_to_move:
        target_path = dst / file_path.name
        try:
            shutil.move(str(file_path), str(target_path))
            print(f"[已移动] {file_path.name} -> {dst}")
            moved_count += 1
        except Exception as e:
            print(f"[移动失败] {file_path.name}: {e}")

    return moved_count

def main():
    # 确保目录存在
    SOURCE_DIR.mkdir(parents=True, exist_ok=True)
    TARGET_DIR.mkdir(parents=True, exist_ok=True)

    loop_count = 0

    while True:
        # 检查是否达到最大循环次数
        if MAX_LOOPS is not None and loop_count >= MAX_LOOPS:
            print("\n[结束] 已达到最大循环次数。")
            break

        # 检查源文件夹是否为空
        current_files = [f for f in SOURCE_DIR.iterdir() if f.is_file()]
        if not current_files:
            print(f"\n=== 第 {loop_count + 1} 轮循环 ===")
            success = run_bash_command(BASH_COMMAND)
            print("\n[结束] 源文件夹已空,任务完成。")
            break

        print(f"\n=== 第 {loop_count + 1} 轮循环 ===")

        # 1. 执行特定的 Bash 命令
        # 假设命令是处理当前源文件夹中的数据。如果命令依赖于具体文件名,
        # 你可能需要在命令中动态插入文件名,或者让命令自行发现文件。
        success = run_bash_command(BASH_COMMAND)

        if not success:
            print("[警告] 命令执行失败,跳过本次文件移动,稍后重试或退出?(此处选择继续下一轮,也可改为 break)")
            # 如果命令失败至关重要,可以取消下面这行的注释来停止脚本
            # break
            time.sleep(2) # 失败后稍微等待
            loop_count += 1
            continue

        # 2. 移动文件
        moved = move_files(SOURCE_DIR, TARGET_DIR, FILES_PER_BATCH)

        if moved == 0:
            print("没有文件可移动。")

        loop_count += 1

        # 可选:每轮循环之间的间隔
        # time.sleep(1)

if __name__ == "__main__":
    main()

我先观察好大概每个切片多大,设置好每次移动的文件数量,就让程序每执行一次推送命令之前,移动一部分文件回到目标文件夹。当命令运行结束后,再次搬运内容,直到临时文件夹搬空。
经过验证,方案完全可行,我也成功把页面部署到了Cloudflare Pages,接下来我就该考虑如何让这个部署行为自动化了。

实现思路

当我确定搞自动化的时候,我就已经想好了应该怎么安排了:

  • 首先,写一套配置文件驱动的前端UI静态文件,通过传入文件目录结构的配置文件,让这个UI能够导航并浏览目录的内容。
  • 一个模块负责转化,把媒体库大于25MB的内容压制到25MB以内,按照原始目录结构整理,并且整理成目录结构配置文件供前端UI使用。
  • 另一个模块负责把文件进行上传和部署,根据预设的规则调配好每次部署的文件大小,避免上传时间超时导致403。

前端实现

不得不说 Gemini 3 Pro 确实是目前前端的神,几下就弄出来了简洁高效的代码。但光有代码还不够,得把逻辑理顺,毕竟我们要搞的是一个能“自动导航”的媒体库,而不是死板的单页播放。
核心思路其实很清晰:动态加载 + 流式播放

目录结构的动态加载

既然文件是切分后上传的,传统的文件夹浏览肯定行不通,浏览器可不会帮你把几千个 .ts 碎片渲染成列表。我们需要一个“地图”,也就是之前提到的目录结构配置文件(比如 index.json)。
这个 JSON 文件由后端脚本生成,记录了所有可用媒体的元数据:图片路径,音频路径,视频的标题和对应的 .m3u8 索引路径,甚至还包括字幕文件的路径。
前端的任务就是 fetch 这个 JSON,然后动态渲染出文件列表。我写了一个简单的递归渲染逻辑,支持多级目录嵌套,这样哪怕你的媒体库深不见底,也能一层层点进去:

async function fetchIndex() {
    try {
        const response = await fetch('./index.json');
        const data = await response.json();

        const defaultItem = findDefaultMedia(data);
        if (defaultItem) {
            playMedia(defaultItem);
        }

        directoryStack = [{ name: "媒体资产库", children: data }];
        renderCurrentDirectory();
    } catch (error) {
        console.error("加载文件索引失败:", error);
        fileTreeEl.innerHTML = '<div style="padding:20px;color:red;">索引加载失败,请确保使用本地服务器运行</div>';
    }
}

这样一来,用户看到的就是一个类似播放器文件列表的整洁界面,点击文件夹进入子目录,点击视频直接开看。

HLS 视频流与字幕的完美融合

重头戏来了:如何在一个页面里既流畅播放切分的 .ts 视频流,又能挂载外挂字幕(.vtt)?
hls.js 的强大之处就在于它对字幕原生的支持。我们不需要自己解析字幕文件,只需要在配置 Hls 实例时,把字幕轨道喂给它就行。
我在之前的播放逻辑上做了升级,增加了一个字幕轨道的管理器。当视频加载时,它会同时拉取字幕文件,并允许用户在播放器原生控制条里切换字幕语言或关闭字幕:

function playMedia(item) {
    const { type, src, subtitles } = item;

    placeholder.style.display = 'none';
    videoViewer.style.display = 'none';
    audioViewer.style.display = 'none';
    imageViewer.style.display = 'none';

    videoViewer.pause();
    audioViewer.pause();
    if (hls) { hls.destroy(); hls = null; }

    if (type === 'video') {
        videoViewer.style.display = 'block';

        videoViewer.querySelectorAll('track').forEach(t => t.remove());

        if (subtitles && subtitles.length > 0) {
            subtitles.forEach(sub => {
                const track = document.createElement('track');
                track.kind = 'subtitles';
                track.label = sub.label;
                track.srclang = sub.srclang;
                track.src = sub.src;

                videoViewer.appendChild(track);

                if (sub.default) {
                    track.track.mode = 'showing';
                }
            });
        }

        loadVideo(src);

    } else if (type === 'audio') {
        audioViewer.style.display = 'block';
        audioViewer.src = src;
        audioViewer.play().catch(e => console.log(e));
    } else if (type === 'image') {
        imageViewer.style.display = 'block';
        imageViewer.src = src;
    }
}

function loadVideo(src) {
    if (Hls.isSupported() && src.endsWith('.m3u8')) {
        hls = new Hls({
            maxBufferLength: 30,
            maxMaxBufferLength: 60
        });
        hls.loadSource(src);
        hls.attachMedia(videoViewer);
        hls.on(Hls.Events.MANIFEST_PARSED, () => {
            videoViewer.play().catch(e => console.log(e));
        });
    } else {
        videoViewer.src = src;
        videoViewer.play().catch(e => console.log(e));
    }
}

最后的拼图

至此,前端的骨架已经搭好了:

  1. 入口:加载 index.json,渲染出漂亮的媒体库网格。
  2. 导航:点击文件夹动态刷新列表,实现无限层级浏览。
  3. 播放:点击视频,hls.js 接管,自动加载切片流和内嵌字幕轨道。

剩下的就是加一点 CSS 动画让切换更顺滑,再适配一下移动端的触摸手势。看着原本躺在硬盘里吃灰的几百 GB 视频,现在变成了一个个可以在云端随意点播、带字幕、还能倍速播放的在线资源,甚至画质还能吊打B站!

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>MediaPages</title>
    <script src="./hls.js"></script>
    <link rel="icon" type="image/png" href="./favicon.png" />
    <style>
        /* 基础重置 */
        html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; color: #ddd; font-family: sans-serif; overflow: hidden; }

        /* 自定义字幕样式 - 优化位置防跳动 */
        ::cue {
            background: rgba(0, 0, 0, 0.6);
            color: white;
            font-size: 1.2rem;
            /* 核心修复:将字幕固定上移,避开原生控制条的高度,防止控制条隐藏时字幕掉落 */
            transform: translateY(-55px);
        }

        /* 极简触发按钮 */
        .menu-toggle-btn {
            position: absolute; top: 20px; left: 20px; z-index: 200;
            background: transparent; border: none; color: rgba(255, 255, 255, 0.8);
            font-size: 16px; cursor: pointer; padding: 10px; outline: none;
            text-shadow: 1px 1px 4px rgba(0,0,0,0.9); transition: opacity 0.5s ease, color 0.2s; opacity: 0;
        }
        .menu-toggle-btn:hover { color: #fff; }

        /* 侧边栏主体 */
        .sidebar {
            position: absolute; top: 0; left: 0;
            /* 核心修改点 1:自适应宽度配置 */
            min-width: 280px;         /* 保持现有最小宽度 */
            width: fit-content;       /* 根据文件名长度自动拉伸 */
            max-width: 90vw;          /* 设置最大宽度为屏幕宽度的90%,防止占满全屏 */
            height: 100%;
            background: rgba(26, 26, 26, 0.8);
            -webkit-backdrop-filter: blur(10px);
            border-right: 1px solid #333;
            display: flex; flex-direction: column;
            transform: translateX(-100%); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            z-index: 101;
        }
        .sidebar.open { transform: translateX(0); }

        /* 动态头部 */
        .sidebar-header {
            padding: 25px 20px 20px 100px;
            font-size: 15px;
            font-weight: bold;
            border-bottom: 1px solid #333;
            /* 核心修改点 2:允许头部目录名换行 */
            white-space: normal;
            word-break: break-all;
        }

        /* 文件列表容器 */
        .tree-container { flex: 1; overflow-y: auto; overflow-x: hidden; }

        /* 返回上一级按钮 */
        .back-btn {
            padding: 12px 20px; background: #222; border-bottom: 1px solid #333;
            cursor: pointer; font-size: 14px; color: #aaa; transition: background 0.2s; display: flex; align-items: center; gap: 8px;
        }
        .back-btn:hover { background: #333; color: #fff; }

        /* 文件列表项 */
        .file-list { list-style: none; padding: 0; margin: 0; }
        .file-list li {
            padding: 12px 20px 12px 25px; cursor: pointer; border-bottom: 1px solid #222;
            font-size: 14px; transition: background 0.2s; display: flex;
            /* 修改为顶部对齐,以便换行时图标与第一行文字对齐 */
            align-items: flex-start;
            gap: 8px;
            /* 核心修改点 3:允许文件名换行 */
            white-space: normal;
            word-break: break-all;
            line-height: 1.4;
        }

        /* 核心修改点 4:防止图标在文字换行时被 flex 压缩 */
        .file-list li span, .back-btn span {
            flex-shrink: 0;
            margin-top: 1px; /* 微调多行状态下图标与文字的视觉居中 */
        }

        .file-list li:hover { background: #333; }
        .file-list li.active { color: #00a1d6; background: rgba(0, 161, 214, 0.1); border-left: 3px solid #00a1d6; padding-left: 22px; }

        /* 文件夹特殊样式 */
        .folder-item { color: #ccc; }
        .folder-item:hover { color: #fff; }

        /* 侧边栏底部版权声明 */
        .sidebar-footer {
            padding: 15px;
            text-align: center;
            border-top: 1px solid #333;
            font-size: 12px;
        }
        .sidebar-footer a {
            color: #666;
            text-decoration: none;
            transition: color 0.2s;
        }
        .sidebar-footer a:hover {
            color: #aaa;
        }

        /* 主展示区 */
        .main-content { width: 100%; height: 100%; position: relative; display: flex; justify-content: center; align-items: center; }
        .media-viewer { width: 100%; height: 100%; display: none; object-fit: contain; outline: none; }
        audio.media-viewer { width: 60%; height: 50px; }
        .placeholder { color: #666; font-size: 16px; display: block; text-align: center; padding: 0 20px; }

        /* ================= 新增:移动端适配 ================= */
        @media (max-width: 768px) {
            .sidebar {
                /* 核心修改点 5:移动端同样保持自适应逻辑 */
                min-width: 80vw;
                width: fit-content;
                max-width: 90vw;
            }
            /* 动态头部边距调整,适应按钮 */
            .sidebar-header { padding: 20px 15px 15px 80px; }
            /* 字幕字体在手机上稍微缩小 */
            ::cue {
                font-size: 1rem;
                transform: translateY(-45px); /* 手机端控制条通常稍矮一点 */
            }
            /* 音频播放器撑满 */
            audio.media-viewer { width: 90%; }
            /* 菜单按钮稍微调小并适应触摸 */
            .menu-toggle-btn {
                top: 15px; left: 15px; font-size: 14px; padding: 8px;
                background: rgba(0, 0, 0, 0.3); /* 移动端增加一点背景提高可见度 */
                border-radius: 6px;
            }
        }
    </style>
</head>
<body>

    <button class="menu-toggle-btn" id="menu-toggle-btn">☰ 媒体库</button>

    <div class="sidebar" id="sidebar">
        <div class="sidebar-header" id="sidebar-header">媒体资产库</div>
        <div class="tree-container" id="file-tree"></div>

        <div class="sidebar-footer">
            <a href="https://github.com/Chocola-X/MediaPages" target="_blank">Powered by MediaPages</a>
        </div>
    </div>

    <div class="main-content">
        <div class="placeholder" id="placeholder">请在左侧选择文件进行预览</div>
        <video id="video-viewer" class="media-viewer" controls playsinline></video>
        <audio id="audio-viewer" class="media-viewer" controls></audio>
        <img id="image-viewer" class="media-viewer" alt="图片预览">
    </div>

    <script>
        const fileTreeEl = document.getElementById('file-tree');
        const sidebarHeader = document.getElementById('sidebar-header');
        const placeholder = document.getElementById('placeholder');
        const videoViewer = document.getElementById('video-viewer');
        const audioViewer = document.getElementById('audio-viewer');
        const imageViewer = document.getElementById('image-viewer');
        const menuToggleBtn = document.getElementById('menu-toggle-btn');
        const sidebar = document.getElementById('sidebar');

        let hls;
        let mouseHideTimeout;
        let directoryStack = [];

        // UI 交互逻辑
        menuToggleBtn.addEventListener('click', () => {
            sidebar.classList.toggle('open');
            updateMenuButtonState();
        });

        function updateMenuButtonState() {
            if (sidebar.classList.contains('open')) {
                menuToggleBtn.innerText = '✕ 关闭';
                menuToggleBtn.style.opacity = '1';
                clearTimeout(mouseHideTimeout);
            } else {
                menuToggleBtn.innerText = '☰ 媒体库';
                startHideTimer();
            }
        }

        function startHideTimer() {
            clearTimeout(mouseHideTimeout);
            if (!sidebar.classList.contains('open')) {
                mouseHideTimeout = setTimeout(() => { menuToggleBtn.style.opacity = '0'; }, 2500);
            }
        }

        // 移动端触摸屏幕也能唤起按钮
        document.addEventListener('mousemove', showButtonAndStartTimer);
        document.addEventListener('touchstart', showButtonAndStartTimer, {passive: true});

        function showButtonAndStartTimer() {
            menuToggleBtn.style.opacity = '1';
            startHideTimer();
        }

        startHideTimer();

        // 核心渲染逻辑
        function findDefaultMedia(items) {
            for (let item of items) {
                if (item.type === 'folder' && item.children) {
                    const found = findDefaultMedia(item.children);
                    if (found) return found;
                } else if (item.isDefault === true) {
                    return item;
                }
            }
            return null;
        }

        async function fetchIndex() {
            try {
                const response = await fetch('./index.json');
                const data = await response.json();

                const defaultItem = findDefaultMedia(data);
                if (defaultItem) {
                    playMedia(defaultItem);
                }

                directoryStack = [{ name: "媒体资产库", children: data }];
                renderCurrentDirectory();
            } catch (error) {
                console.error("加载文件索引失败:", error);
                fileTreeEl.innerHTML = '<div style="padding:20px;color:red;">索引加载失败,请确保使用本地服务器运行</div>';
            }
        }

        function renderCurrentDirectory() {
            fileTreeEl.innerHTML = '';
            const currentDir = directoryStack[directoryStack.length - 1];
            sidebarHeader.innerText = currentDir.name;

            if (directoryStack.length > 1) {
                const backBtn = document.createElement('div');
                backBtn.className = 'back-btn';
                backBtn.innerHTML = '<span>⬅️</span> 返回上一级';
                backBtn.onclick = () => {
                    directoryStack.pop();
                    renderCurrentDirectory();
                };
                fileTreeEl.appendChild(backBtn);
            }

            const ul = document.createElement('ul');
            ul.className = 'file-list';

            currentDir.children.forEach(item => {
                const li = document.createElement('li');

                if (item.type === 'folder') {
                    li.className = 'folder-item';
                    li.innerHTML = `<span>📁</span> ${item.name}`;
                    li.onclick = () => {
                        directoryStack.push(item);
                        renderCurrentDirectory();
                    };
                } else {
                    const icon = item.type === 'video' ? '🎬' : (item.type === 'audio' ? '🎵' : '🖼️');
                    li.innerHTML = `<span>${icon}</span> ${item.name}`;
                    li.title = item.name; // 添加 title 方便鼠标悬停查看完整长文件名

                    li.onclick = () => {
                        document.querySelectorAll('.file-list li').forEach(el => el.classList.remove('active'));
                        li.classList.add('active');
                        playMedia(item);

                        // 移动端优化:点击播放后自动收起侧边栏
                        if (window.innerWidth <= 768) {
                            sidebar.classList.remove('open');
                            updateMenuButtonState();
                        }
                    };
                }
                ul.appendChild(li);
            });

            fileTreeEl.appendChild(ul);
        }

        // 播放与字幕逻辑
        function playMedia(item) {
            const { type, src, subtitles } = item;

            placeholder.style.display = 'none';
            videoViewer.style.display = 'none';
            audioViewer.style.display = 'none';
            imageViewer.style.display = 'none';

            videoViewer.pause();
            audioViewer.pause();
            if (hls) { hls.destroy(); hls = null; }

            if (type === 'video') {
                videoViewer.style.display = 'block';

                videoViewer.querySelectorAll('track').forEach(t => t.remove());

                if (subtitles && subtitles.length > 0) {
                    subtitles.forEach(sub => {
                        const track = document.createElement('track');
                        track.kind = 'subtitles';
                        track.label = sub.label;
                        track.srclang = sub.srclang;
                        track.src = sub.src;

                        videoViewer.appendChild(track);

                        if (sub.default) {
                            track.track.mode = 'showing';
                        }
                    });
                }

                loadVideo(src);

            } else if (type === 'audio') {
                audioViewer.style.display = 'block';
                audioViewer.src = src;
                audioViewer.play().catch(e => console.log(e));
            } else if (type === 'image') {
                imageViewer.style.display = 'block';
                imageViewer.src = src;
            }
        }

        function loadVideo(src) {
            if (Hls.isSupported() && src.endsWith('.m3u8')) {
                hls = new Hls({
                    maxBufferLength: 30,
                    maxMaxBufferLength: 60
                });
                hls.loadSource(src);
                hls.attachMedia(videoViewer);
                hls.on(Hls.Events.MANIFEST_PARSED, () => {
                    videoViewer.play().catch(e => console.log(e));
                });
            } else {
                videoViewer.src = src;
                videoViewer.play().catch(e => console.log(e));
            }
        }

        fetchIndex();
    </script>
</body>
</html>

接下来,就要把这套前端逻辑和之前的自动化上传脚本彻底打通,实现“丢进文件夹,自动变网站”的终极梦想了。

转换模块

这个模块也是开发过程中碰壁比较多的模块,因为涉及媒体的转化和大小控制,一不小心就超了,需要反复调试。
在调试的过程中,我发现对于1080P 60帧的高质量番剧,每个切片6秒,180帧一个关键帧是很不错的选择。既可以保证每个文件有一定的大小不至于太过于碎片化占用上传文件数,又可以确保当番剧到了画面变化较大的地方,单个切片仍然可以控制在25MB的大小限制内。
对应的参数为

hw_cmd = [
    'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda',
    '-i', input_path,
    '-map', '0:v:0', '-map', '0:a:0',
    '-vf', 'scale_cuda=format=nv12',
    '-c:v', 'h264_nvenc',
    '-preset', 'p6',
    '-profile:v', 'high', '-level:v', '5.2',
    '-rc', 'vbr', '-b:v', '0', '-maxrate', '8M', '-bufsize', '16M',
    '-g', '180', '-keyint_min', '180', '-sc_threshold', '0',
    '-c:a', 'aac', '-b:a', '320k',
    '-hls_time', '6', '-hls_playlist_type', 'vod',
    '-hls_segment_filename', hls_segments,
    output_playlist
]

而对于4K,则应该设置每个切片3秒,同样是180帧一个关键帧。

hw_cmd = [
    'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda',
    '-i', input_path,
    '-map', '0:v:0', '-map', '0:a:0',
    '-vf', 'scale_cuda=format=nv12',
    '-c:v', 'h264_nvenc',
    '-preset', 'p6',
    '-profile:v', 'high', '-level:v', '5.2',
    '-rc', 'vbr', '-b:v', '0', '-maxrate', '24M', '-bufsize', '36M',
    '-g', '180', '-keyint_min', '180', '-sc_threshold', '0',
    '-c:a', 'aac', '-b:a', '320k',
    '-hls_time', '3', '-hls_playlist_type', 'vod',
    '-hls_segment_filename', hls_segments,
    output_playlist
]

注意'-hls_time', '3'这里,你也可以根据你的需要去尝试不同的参数,以及码率的设置部分'-b:v', '18M', '-maxrate', '24M', '-bufsize', '36M',,我这里只是提供一个建议性的数值。毕竟是小工具,代码量不大,自己改改就行。
程序的大体逻辑就是,对于文件进行分别的处理,小于25MB的部分直接复制,如果是MKV的视频转化成浏览器支持的MP4视频。
而大于25MB的音频和图片文件则直接进行压缩,确保大小符合要求。视频就进行上面说的切分转换。
具体的音频转化函数:

def compress_audio(input_path, output_path):
    logging.info(f"Starting precise audio compression: {input_path}")
    temp_output = output_path + '.tmp'

    try:
        duration = get_audio_duration(input_path)
        orig_bitrate = get_audio_bitrate(input_path)
        if duration <= 0: duration = 60

        safe_target_bytes = 23.5 * MB
        calculated_kbps = int((safe_target_bytes * 8) / (duration * 1000))

        target_kbps = calculated_kbps
        if orig_bitrate > 0:
            target_kbps = min(target_kbps, orig_bitrate)
        target_kbps = min(target_kbps, 320)
        target_kbps = max(target_kbps, 32)

        while True:
            cmd = [
                'ffmpeg', '-y', '-i', input_path,
                '-c:a', 'libmp3lame', '-b:a', f'{target_kbps}k',
                '-f', 'mp3', temp_output
            ]
            subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL)

            final_size = os.path.getsize(temp_output)
            if final_size <= MAX_SIZE or target_kbps <= 32:
                shutil.move(temp_output, output_path)
                break

            target_kbps = int(target_kbps * 0.8)
            if target_kbps < 32: target_kbps = 32

    except Exception as e:
        logging.error(f"Error compressing audio {input_path}: {e}")
        shutil.copy2(input_path, output_path)
    finally:
        if os.path.exists(temp_output):
            os.remove(temp_output)

小体积视频转化函数:

def convert_video_to_mp4(input_path, output_path):
    """用于处理小于 MAX_SIZE 的视频,仅进行标准 MP4 转码以确保浏览器兼容性"""
    logging.info(f"Standardizing small video to MP4: {input_path}")
    target_output = os.path.splitext(output_path)[0] + '.mp4'

    hw_cmd = [
        'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda',
        '-i', input_path,
        '-vf', 'scale_cuda=format=nv12',
        '-c:v', 'h264_nvenc', '-preset', 'p6', '-tune', 'hq',
        '-profile:v', 'high', '-cq', '22',
        '-c:a', 'aac', '-b:a', '320k',
        '-movflags', '+faststart',
        target_output
    ]

    try:
        subprocess.run(hw_cmd, check=True, stderr=subprocess.DEVNULL)
        return True
    except subprocess.CalledProcessError:
        sw_cmd = [
            'ffmpeg', '-y', '-i', input_path,
            '-c:v', 'libx264', '-preset', 'slow', '-crf', '22',
            '-pix_fmt', 'yuv420p',
            '-c:a', 'aac', '-b:a', '320k',
            '-movflags', '+faststart',
            target_output
        ]
        try:
            subprocess.run(sw_cmd, check=True, stderr=subprocess.DEVNULL)
            return True
        except Exception as e:
            logging.error(f"Failed to convert {input_path}: {e}")
            if os.path.exists(target_output): os.remove(target_output)
            return False

大体积视频转化函数:

def compress_video(input_path, output_dir):
    """采用 HLS 切片方式处理视频,支持全格式和 CUDA 硬件加速"""
    input_dir_path = os.path.dirname(input_path)
    base_name = os.path.splitext(os.path.basename(input_path))[0]

    video_folder = os.path.join(output_dir, base_name)
    os.makedirs(video_folder, exist_ok=True)
    logging.info(f"Starting high-performance HLS processing: {input_path}")

    output_playlist = os.path.join(video_folder, f"{base_name}-0.m3u8")
    hls_segments = os.path.join(video_folder, "chunk_%04d.ts")

    # 基于用户提供的高性能 CUDA 优化参数
    hw_cmd = [
        'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda',
        '-i', input_path,
        '-map', '0:v:0', '-map', '0:a:0',
        '-vf', 'scale_cuda=format=nv12',
        '-c:v', 'h264_nvenc',
        '-preset', 'p6',
        '-profile:v', 'high', '-level:v', '5.2',
        '-rc', 'vbr', '-b:v', '0', '-maxrate', '8M', '-bufsize', '16M',
        '-g', '180', '-keyint_min', '180', '-sc_threshold', '0',
        '-c:a', 'aac', '-b:a', '320k',
        '-hls_time', '6', '-hls_playlist_type', 'vod',
        '-hls_segment_filename', hls_segments,
        output_playlist
    ]

    try:
        subprocess.run(hw_cmd, check=True, stderr=subprocess.DEVNULL)
    except Exception as e:
        logging.warning(f"CUDA failed, falling back to CPU for {base_name}. Error: {e}")
        sw_cmd = [
            'ffmpeg', '-y', '-i', input_path,
            '-map', '0:v:0', '-map', '0:a:0',
            '-vf', 'scale=format=yuv420p',
            '-c:v', 'libx264', '-preset', 'medium', '-crf', '20',
            '-g', '180', '-keyint_min', '180', '-sc_threshold', '0',
            '-c:a', 'aac', '-b:a', '320k',
            '-hls_time', '3', '-hls_playlist_type', 'vod',
            '-hls_segment_filename', hls_segments,
            output_playlist
        ]
        subprocess.run(sw_cmd, check=True, stderr=subprocess.DEVNULL)

    # 智能字幕处理 (保持原逻辑不变)
    process_subtitles(input_dir_path, base_name, video_folder, input_path)

以及对应可能的字幕处理:

def process_subtitles(input_dir_path, base_name, video_folder, input_video_path):
    """提取并转换关联字幕"""
    try:
        source_items = os.listdir(input_dir_path)
        for item in source_items:
            if item.startswith(base_name) and item.lower().endswith(tuple(SUBTITLE_EXTENSIONS)):
                if item == os.path.basename(input_video_path): continue

                subtitle_file = os.path.join(input_dir_path, item)
                sub_name_without_ext = os.path.splitext(item)[0]
                suffix = re.sub(r'^[\.\-\_]+', '', sub_name_without_ext[len(base_name):])
                if not suffix: suffix = "Default"

                vtt_filename = f"{base_name}_{suffix}.vtt"
                vtt_path = os.path.join(video_folder, vtt_filename)

                if item.lower().endswith('.ass'):
                    filtered_subtitle = os.path.join(video_folder, f"temp_{suffix}.ass")
                    try:
                        with open(subtitle_file, 'r', encoding='utf-8') as f_in:
                            lines = f_in.readlines()
                        with open(filtered_subtitle, 'w', encoding='utf-8') as f_out:
                            for line in lines:
                                if line.startswith('Dialogue:'):
                                    parts = line.split(',', 9)
                                    if len(parts) >= 4:
                                        style_lower = parts[3].lower()
                                        words = re.sub(r'[^a-z0-9]', ' ', style_lower).split()
                                        if any(x in words for x in ['jp', 'jpn']) or '日' in style_lower or 'romaji' in style_lower:
                                            continue
                                f_out.write(line)
                        subprocess.run(['ffmpeg', '-y', '-i', filtered_subtitle, vtt_path], check=True, stderr=subprocess.DEVNULL)
                    finally:
                        if os.path.exists(filtered_subtitle): os.remove(filtered_subtitle)
                else:
                    subprocess.run(['ffmpeg', '-y', '-i', subtitle_file, vtt_path], check=True, stderr=subprocess.DEVNULL)
    except Exception as e:
        logging.error(f"Subtitle error for {base_name}: {e}")

字幕处理主要是针对动画的,去除日文字幕,防止转化为VTT字幕后混乱。
整理清楚逻辑后,我们就得到了convert.py:

import os
import sys
import shutil
import subprocess
import argparse
import json
from PIL import Image
import math
import re
import logging

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='[%(asctime)s] %(levelname)s: %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
)

# 常量定义
MB = 1024 * 1024
MAX_SIZE = 24 * MB  # 24MB

# 支持的媒体类型
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.flac', '.ogg'}
VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.mov', '.wmv', '.webm'}
SUBTITLE_EXTENSIONS = {'.ass', '.srt', '.vtt'}

def get_audio_duration(file_path):
    cmd = [
        'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
        '-of', 'default=noprint_wrappers=1:nokey=1', file_path
    ]
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    if result.returncode == 0:
        return float(result.stdout.strip())
    return 60

def get_audio_bitrate(file_path):
    cmd = [
        'ffprobe', '-v', 'error', '-show_entries', 'stream=bit_rate',
        '-of', 'default=noprint_wrappers=1:nokey=1', file_path
    ]
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    if result.returncode == 0:
        bitrate_str = result.stdout.strip().split('\n')[0]
        if bitrate_str and bitrate_str != 'N/A':
            return int(float(bitrate_str) / 1000)
    return 0

def compress_image(input_path, output_path):
    logging.info(f"Starting image compression: {input_path}")
    try:
        img = Image.open(input_path)
        if img.mode != 'RGB': img = img.convert('RGB')

        ext = os.path.splitext(output_path)[1].lower()
        if ext not in {'.jpg', '.jpeg', '.png', '.gif', '.webp'}:
            output_path = os.path.splitext(output_path)[0] + '.webp'

        quality = 95
        while quality >= 20:
            img.save(output_path, 'WEBP', quality=quality)
            if os.path.getsize(output_path) <= MAX_SIZE:
                return
            quality -= 5

        scale = 0.9
        while scale > 0.3:
            new_size = (int(img.width * scale), int(img.height * scale))
            img_resized = img.resize(new_size, Image.LANCZOS)
            quality = 95
            while quality >= 20:
                img_resized.save(output_path, 'WEBP', quality=quality)
                if os.path.getsize(output_path) <= MAX_SIZE:
                    return
                quality -= 5
            scale -= 0.1

        img.save(output_path, 'WEBP', quality=20)
    except Exception as e:
        logging.error(f"Image compression error: {e}")
        shutil.copy2(input_path, output_path)

def compress_audio(input_path, output_path):
    logging.info(f"Starting precise audio compression: {input_path}")
    temp_output = output_path + '.tmp'

    try:
        duration = get_audio_duration(input_path)
        orig_bitrate = get_audio_bitrate(input_path)
        if duration <= 0: duration = 60

        safe_target_bytes = 23.5 * MB
        calculated_kbps = int((safe_target_bytes * 8) / (duration * 1000))

        target_kbps = calculated_kbps
        if orig_bitrate > 0:
            target_kbps = min(target_kbps, orig_bitrate)
        target_kbps = min(target_kbps, 320)
        target_kbps = max(target_kbps, 32)

        while True:
            cmd = [
                'ffmpeg', '-y', '-i', input_path,
                '-c:a', 'libmp3lame', '-b:a', f'{target_kbps}k',
                '-f', 'mp3', temp_output
            ]
            subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL)

            final_size = os.path.getsize(temp_output)
            if final_size <= MAX_SIZE or target_kbps <= 32:
                shutil.move(temp_output, output_path)
                break

            target_kbps = int(target_kbps * 0.8)
            if target_kbps < 32: target_kbps = 32

    except Exception as e:
        logging.error(f"Error compressing audio {input_path}: {e}")
        shutil.copy2(input_path, output_path)
    finally:
        if os.path.exists(temp_output):
            os.remove(temp_output)

def convert_video_to_mp4(input_path, output_path):
    """用于处理小于 MAX_SIZE 的视频,仅进行标准 MP4 转码以确保浏览器兼容性"""
    logging.info(f"Standardizing small video to MP4: {input_path}")
    target_output = os.path.splitext(output_path)[0] + '.mp4'

    hw_cmd = [
        'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda',
        '-i', input_path,
        '-vf', 'scale_cuda=format=nv12',
        '-c:v', 'h264_nvenc', '-preset', 'p6', '-tune', 'hq',
        '-profile:v', 'high', '-cq', '22',
        '-c:a', 'aac', '-b:a', '320k',
        '-movflags', '+faststart',
        target_output
    ]

    try:
        subprocess.run(hw_cmd, check=True, stderr=subprocess.DEVNULL)
        return True
    except subprocess.CalledProcessError:
        sw_cmd = [
            'ffmpeg', '-y', '-i', input_path,
            '-c:v', 'libx264', '-preset', 'slow', '-crf', '22',
            '-pix_fmt', 'yuv420p',
            '-c:a', 'aac', '-b:a', '320k',
            '-movflags', '+faststart',
            target_output
        ]
        try:
            subprocess.run(sw_cmd, check=True, stderr=subprocess.DEVNULL)
            return True
        except Exception as e:
            logging.error(f"Failed to convert {input_path}: {e}")
            if os.path.exists(target_output): os.remove(target_output)
            return False

def compress_video(input_path, output_dir):
    """采用 HLS 切片方式处理视频,支持全格式和 CUDA 硬件加速"""
    input_dir_path = os.path.dirname(input_path)
    base_name = os.path.splitext(os.path.basename(input_path))[0]

    video_folder = os.path.join(output_dir, base_name)
    os.makedirs(video_folder, exist_ok=True)
    logging.info(f"Starting high-performance HLS processing: {input_path}")

    output_playlist = os.path.join(video_folder, f"{base_name}-0.m3u8")
    hls_segments = os.path.join(video_folder, "chunk_%04d.ts")

    # 基于用户提供的高性能 CUDA 优化参数
    hw_cmd = [
        'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda',
        '-i', input_path,
        '-map', '0:v:0', '-map', '0:a:0',
        '-vf', 'scale_cuda=format=nv12',
        '-c:v', 'h264_nvenc',
        '-preset', 'p6',
        '-profile:v', 'high', '-level:v', '5.2',
        '-rc', 'vbr', '-b:v', '0', '-maxrate', '8M', '-bufsize', '16M',
        '-g', '180', '-keyint_min', '180', '-sc_threshold', '0',
        '-c:a', 'aac', '-b:a', '320k',
        '-hls_time', '3', '-hls_playlist_type', 'vod',
        '-hls_segment_filename', hls_segments,
        output_playlist
    ]

    try:
        subprocess.run(hw_cmd, check=True, stderr=subprocess.DEVNULL)
    except Exception as e:
        logging.warning(f"CUDA failed, falling back to CPU for {base_name}. Error: {e}")
        sw_cmd = [
            'ffmpeg', '-y', '-i', input_path,
            '-map', '0:v:0', '-map', '0:a:0',
            '-vf', 'scale=format=yuv420p',
            '-c:v', 'libx264', '-preset', 'medium', '-crf', '20',
            '-g', '180', '-keyint_min', '180', '-sc_threshold', '0',
            '-c:a', 'aac', '-b:a', '320k',
            '-hls_time', '3', '-hls_playlist_type', 'vod',
            '-hls_segment_filename', hls_segments,
            output_playlist
        ]
        subprocess.run(sw_cmd, check=True, stderr=subprocess.DEVNULL)

    # 智能字幕处理 (保持原逻辑不变)
    process_subtitles(input_dir_path, base_name, video_folder, input_path)

def process_subtitles(input_dir_path, base_name, video_folder, input_video_path):
    """提取并转换关联字幕"""
    try:
        source_items = os.listdir(input_dir_path)
        for item in source_items:
            if item.startswith(base_name) and item.lower().endswith(tuple(SUBTITLE_EXTENSIONS)):
                if item == os.path.basename(input_video_path): continue

                subtitle_file = os.path.join(input_dir_path, item)
                sub_name_without_ext = os.path.splitext(item)[0]
                suffix = re.sub(r'^[\.\-\_]+', '', sub_name_without_ext[len(base_name):])
                if not suffix: suffix = "Default"

                vtt_filename = f"{base_name}_{suffix}.vtt"
                vtt_path = os.path.join(video_folder, vtt_filename)

                if item.lower().endswith('.ass'):
                    filtered_subtitle = os.path.join(video_folder, f"temp_{suffix}.ass")
                    try:
                        with open(subtitle_file, 'r', encoding='utf-8') as f_in:
                            lines = f_in.readlines()
                        with open(filtered_subtitle, 'w', encoding='utf-8') as f_out:
                            for line in lines:
                                if line.startswith('Dialogue:'):
                                    parts = line.split(',', 9)
                                    if len(parts) >= 4:
                                        style_lower = parts[3].lower()
                                        words = re.sub(r'[^a-z0-9]', ' ', style_lower).split()
                                        if any(x in words for x in ['jp', 'jpn']) or '日' in style_lower or 'romaji' in style_lower:
                                            continue
                                f_out.write(line)
                        subprocess.run(['ffmpeg', '-y', '-i', filtered_subtitle, vtt_path], check=True, stderr=subprocess.DEVNULL)
                    finally:
                        if os.path.exists(filtered_subtitle): os.remove(filtered_subtitle)
                else:
                    subprocess.run(['ffmpeg', '-y', '-i', subtitle_file, vtt_path], check=True, stderr=subprocess.DEVNULL)
    except Exception as e:
        logging.error(f"Subtitle error for {base_name}: {e}")

def build_index_tree(dir_path, output_root):
    children = []
    try:
        items = sorted(os.listdir(dir_path))
    except Exception:
        return children

    for item in items:
        full_path = os.path.join(dir_path, item)
        raw_rel_path = os.path.relpath(full_path, output_root).replace('\\', '/')
        web_src = f"./{raw_rel_path}"

        if os.path.isdir(full_path):
            try:
                folder_items = os.listdir(full_path)
            except:
                folder_items = []

            m3u8_files = [f for f in folder_items if f.lower().endswith('.m3u8')]
            if m3u8_files:
                m3u8_file = m3u8_files[0]
                m3u8_rel = os.path.relpath(os.path.join(full_path, m3u8_file), output_root).replace('\\', '/')

                subtitles = []
                vtt_files = [f for f in folder_items if f.lower().endswith('.vtt')]
                base_name = item

                for vtt in vtt_files:
                    vtt_name_no_ext = os.path.splitext(vtt)[0]
                    suffix = vtt_name_no_ext[len(base_name)+1:] if vtt_name_no_ext.startswith(base_name + "_") else vtt_name_no_ext
                    label = suffix
                    suffix_lower = suffix.lower()

                    if any(x in suffix_lower for x in ['sc', 'chs', 'zh']):
                        srclang, default = "zh", True
                    elif any(x in suffix_lower for x in ['tc', 'cht', 'hant']):
                        srclang, default = "zh-Hant", False
                    else:
                        srclang, default = "und", False

                    vtt_rel = os.path.relpath(os.path.join(full_path, vtt), output_root).replace('\\', '/')
                    subtitles.append({
                        "label": label, "srclang": srclang, "src": f"./{vtt_rel}", "default": default
                    })

                video_node = {"name": item, "type": "video", "src": f"./{m3u8_rel}"}
                if subtitles: video_node["subtitles"] = subtitles
                children.append(video_node)
            else:
                folder_children = build_index_tree(full_path, output_root)
                if folder_children:
                    children.append({"name": item, "type": "folder", "children": folder_children})
        else:
            ext = os.path.splitext(item)[1].lower()
            if item == 'index.json': continue
            if ext in AUDIO_EXTENSIONS:
                children.append({"name": item, "type": "audio", "src": web_src})
            elif ext in IMAGE_EXTENSIONS:
                children.append({"name": item, "type": "image", "src": web_src})
            elif ext in VIDEO_EXTENSIONS:
                # 注意:如果已经被 HLS 处理,它将作为文件夹存在,这里处理的是未被转码的小视频
                children.append({"name": item, "type": "video", "src": web_src})

    return children

def generate_index(output_dir, set_default=True):
    logging.info("Generating nested JSON index...")
    root_nodes = build_index_tree(output_dir, output_dir)
    if set_default:
        for node in root_nodes:
            if node.get("type") == "video":
                node["isDefault"] = True
                break
    index_path = os.path.join(output_dir, 'index.json')
    with open(index_path, 'w', encoding='utf-8') as f:
        json.dump(root_nodes, f, indent=2, ensure_ascii=False)

def main():
    parser = argparse.ArgumentParser(description='Universal Media Processing Tool')
    parser.add_argument('input_dir', help='Input directory path')
    parser.add_argument('output_dir', help='Output directory path')
    parser.add_argument('--no-default', action='store_true')
    args = parser.parse_args()

    os.makedirs(args.output_dir, exist_ok=True)
    logging.info("--- Media Processing Tool Started ---")

    for root, _, files in os.walk(args.input_dir):
        rel_path = os.path.relpath(root, args.input_dir)
        out_root = os.path.join(args.output_dir, rel_path)
        os.makedirs(out_root, exist_ok=True)

        for file in files:
            input_path = os.path.join(root, file)
            output_path = os.path.join(out_root, file)
            ext = os.path.splitext(file)[1].lower()
            file_size = os.path.getsize(input_path)

            # 检查是否已处理过 (MP4 转换或 HLS 文件夹)
            if ext in VIDEO_EXTENSIONS:
                base_name = os.path.splitext(file)[0]
                if os.path.exists(os.path.join(out_root, base_name, f"{base_name}-0.m3u8")):
                    continue

            # --- 核心处理逻辑 ---
            if ext in IMAGE_EXTENSIONS:
                if file_size <= MAX_SIZE: shutil.copy2(input_path, output_path)
                else: compress_image(input_path, output_path)

            elif ext in AUDIO_EXTENSIONS:
                if file_size <= MAX_SIZE: shutil.copy2(input_path, output_path)
                else: compress_audio(input_path, output_path)

            elif ext in VIDEO_EXTENSIONS:
                # 无论 MKV 还是 MP4,大于 MAX_SIZE 都进行 HLS 高性能切片
                if file_size > MAX_SIZE:
                    compress_video(input_path, out_root)
                else:
                    # 小视频统一转成兼容性好的 MP4
                    convert_video_to_mp4(input_path, output_path)

            elif ext in SUBTITLE_EXTENSIONS:
                if ext != '.ass': # .ass 由 compress_video 内部处理,此处仅复制 srt/vtt
                    shutil.copy2(input_path, output_path)

    # 清理残留 ass
    for root_dir, _, out_files in os.walk(args.output_dir):
        for f in out_files:
            if f.lower().endswith('.ass'):
                try: os.remove(os.path.join(root_dir, f))
                except: pass

    generate_index(args.output_dir, set_default=not args.no_default)
    logging.info("--- Processing Complete ---")

if __name__ == "__main__":
    main()

这个文件是可以单独调用的,具体的方法如下:

python convert.py '/run/media/chocola/3T-DATA02/[hyakuhuyu&VCB-Studio] Re Zero kara Hajimeru Isekai Seikatsu 3rd Season [Ma10p_1080p]/' '/run/media/chocola/3T-DATA02/wwwroot/MediaPages/test2'

第一个目录是来源目录,第二个目录是输出目录。以及还有一个参数--no-default,如果加上则不会选取文件夹根目录第一个文件作为打开页面时的默认展示媒体。

上传模块

这个部分就相对简单了,无非就是最开始那个测试demo的增强版。
但是我稍微优化了一下,因为考虑到每个媒体库处理后的结果肯定都不一样,所以我设计成了计算文件大小,按照设置的每次上传的大小分批次移动文件。
思路大概是这样的:设置一个缓存文件夹,先把目标文件夹的内容搬到小于设置的阈值大小,执行一次上传,上传完成后从缓存文件夹搬回来阈值大小内的文件,再次执行上传,如此往复,直到上传完成。
程序大概就长这样:
upload.py

import os
import shutil
import subprocess
import argparse
from pathlib import Path

def get_directory_size_and_files(directory: Path):
    """
    计算目录下所有文件的总大小,并返回文件列表及其大小信息。
    """
    total_size = 0
    file_list = []
    for root, dirs, files in os.walk(directory):
        for name in files:
            filepath = Path(root) / name
            try:
                size = filepath.stat().st_size
                total_size += size
                rel_path = filepath.relative_to(directory)
                file_list.append((rel_path, size, filepath))
            except OSError as e:
                print(f"无法获取文件大小 {filepath}: {e}")
    # 按文件大小降序排列
    file_list.sort(key=lambda x: x[1], reverse=True)
    return total_size, file_list


def move_files_with_structure(file_list, source_base: Path, dest_base: Path):
    """
    将文件列表中的文件连同其目录结构一起移动到新位置。
    """
    for i, (rel_path, size, _) in enumerate(file_list):
        src_file = source_base / rel_path
        dest_file = dest_base / rel_path

        if src_file.exists():
            dest_file.parent.mkdir(parents=True, exist_ok=True)

            try:
                shutil.move(str(src_file), str(dest_file))
                if (i + 1) % 500 == 0 or (i + 1) == len(file_list):
                    print(f"  -> 已移动 {i + 1}/{len(file_list)} 个文件...")
            except Exception as e:
                print(f"[移动失败] {src_file}: {e}")
    print(f"  -> 文件移动操作完成。")


def run_bash_command(cmd):
    """执行 Bash 命令并返回是否成功"""
    print(f"\n[执行命令] {cmd}")
    print("  -> 命令开始执行...")
    try:
        result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True)
        print("  -> 命令执行完毕。")
        print("[命令输出]:", result.stdout.strip())
        return True
    except subprocess.CalledProcessError as e:
        print(f"[命令失败]: {e}")
        if e.stderr:
            print("[错误信息]:", e.stderr.strip())
        return False


def run_deploy(target_dir, cache_dir, bash_cmd, threshold, resume=False):
    """
    接收外部传入的路径和参数执行部署逻辑
    """
    print(f"目标目录: {target_dir}")
    print(f"缓存目录: {cache_dir}")
    print(f"大小阈值: {threshold / (1024**2):.2f} MB")
    print(f"断点续传: {'开启' if resume else '关闭'}\n")

    if resume:
        # 断点续传模式:直接对目标文件夹当前的内容重试部署,跳过清空阶段
        print("--- 恢复模式:尝试重新部署当前目标目录中的文件 ---")
        success = run_bash_command(bash_cmd)
        if not success:
            print("[错误] 恢复模式下部署重试失败,程序终止。请检查网络或重新运行。")
            return
        print("  -> 恢复部署成功,直接进入阶段二,继续从缓存搬运文件...")
    else:
        # 第一阶段:正常模式,将所有超出阈值的文件搬到缓存
        print("--- 阶段一:清空目标目录 ---")
        initial_size, initial_files = get_directory_size_and_files(target_dir)
        print(f"初始目录总大小: {initial_size / (1024**2):.2f} MB")

        if initial_size > threshold:
            files_to_move_initially = []
            size_moved = 0
            min_size_to_free = initial_size - threshold

            print(f"需要释放至少 {min_size_to_free / (1024**2):.2f} MB 的空间")

            for rel_path, size, full_path in initial_files:
                if size_moved < min_size_to_free:
                    files_to_move_initially.append((rel_path, size, full_path))
                    size_moved += size
                else:
                    break

            print(f"准备搬运 {len(files_to_move_initially)} 个文件到缓存")
            move_files_with_structure(files_to_move_initially, target_dir, cache_dir)

            remaining_size_after_initial_move, _ = get_directory_size_and_files(target_dir)
            print(f"搬运后,目标目录剩余大小: {remaining_size_after_initial_move / (1024**2):.2f} MB")

            # 执行第一次命令
            print("\n[步骤] 执行首次部署命令...")
            success = run_bash_command(bash_cmd)
            if not success:
                print("[错误] 首次命令执行失败,程序终止。")
                return
        else:
            print("初始大小已满足阈值要求,无需搬运。")
            print("\n[步骤] 执行初始部署命令...")
            success = run_bash_command(bash_cmd)
            if not success:
                print("[错误] 初始命令执行失败,程序终止。")
                return

    # 第二阶段:从缓存分批取回文件并执行命令
    print("\n--- 阶段二:从缓存取回文件并部署 ---")
    loop_count = 0
    while True:
        loop_count += 1
        print(f"\n--- 取回循环 #{loop_count} ---")

        # 检查缓存是否还有文件
        cached_size, cached_files = get_directory_size_and_files(cache_dir)
        if cached_size == 0:
            print("缓存目录已空,进入最终步骤。")
            break

        # 从缓存中选取不超过阈值的文件
        files_to_return = []
        size_to_return = 0
        for rel_path, size, full_path in cached_files:
            if size_to_return + size <= threshold:
                files_to_return.append((rel_path, size, full_path))
                size_to_return += size
            else:
                if len(files_to_return) == 0:
                    print(f"  -> 警告: 单个文件超过阈值,但仍将其取出。")
                    files_to_return.append((rel_path, size, full_path))
                    size_to_return = size
                break

        print(f"  -> 从缓存取回 {len(files_to_return)} 个文件,总大小 {size_to_return / (1024**2):.2f} MB")
        move_files_with_structure(files_to_return, cache_dir, target_dir)

        # 执行命令
        print(f"  -> 执行部署命令...")
        success = run_bash_command(bash_cmd)
        if not success:
            print("[错误] 命令执行失败,程序终止。如果是因为网络波动,可以加上 -r 参数重新运行来续传。")
            return

    # 第三阶段:当缓存为空时,执行最终命令
    print("\n--- 阶段三:完成任务 ---")
    print("\n[最终步骤] 执行最终部署命令,此时所有文件都已回到目标目录。")
    success = run_bash_command(bash_cmd)
    if not success:
        print("[错误] 最终命令执行失败。")
        return

    print("\n任务完成!所有文件已处理并完成最终部署。")


def main():
    parser = argparse.ArgumentParser(description="Cloudflare Pages 分批上传部署工具")
    parser.add_argument("-t", "--target", required=True, help="目标文件夹目录 (需要部署的内容所在路径)")
    parser.add_argument("-c", "--cache", required=True, help="缓存文件夹目录 (用于上传过程中的中转存放)")
    parser.add_argument("-p", "--project", required=True, help="远端 Cloudflare Pages 项目名称")
    parser.add_argument("--threshold", type=int, default=512, help="单次上传阈值(MB),默认 512")

    # 新增的断点续传参数
    parser.add_argument("-r", "--resume", action="store_true", help="启用断点续传:优先上传当前目标文件夹内剩余的文件,再继续从缓存搬运")

    args = parser.parse_args()

    target_dir = Path(args.target).resolve()
    cache_dir = Path(args.cache).resolve()

    if not target_dir.exists():
        print(f"[错误] 目标目录不存在: {target_dir}")
        sys.exit(1)

    # 确保缓存目录存在
    cache_dir.mkdir(parents=True, exist_ok=True)

    # 动态拼接 wrangler 部署命令
    bash_cmd = f"npx wrangler pages deploy {target_dir} --project-name {args.project}"
    threshold_bytes = args.threshold * 1024 * 1024

    print(f"--- 启动独立部署脚本 ---")
    print(f"远端项目: {args.project}")

    run_deploy(target_dir, cache_dir, bash_cmd, threshold_bytes, resume=args.resume)


if __name__ == "__main__":
    main()

convert.py,这个upload.py也是可以进行单独调用的,调用实例:

python upload.py -t "/run/media/chocola/3T-DATA02/Projects/Pages/work/dist" -c "/run/media/chocola/3T-DATA02/Projects/Pages/work/tmp" -p "frieren-anime" --threshold 1024

其中的参数:

  • -t 上传的目标文件夹
  • -c 用于文件搬运缓存的文件夹
  • -p Cloudflare Pages 项目名称
  • --threshold 每次上传的大小,可选参数,默认512,单位为MB。

眼尖的朋友应该也注意到了,我为了应对网络不稳定造成的上传中断,提供了恢复模式。如果是以恢复模式启动,也就是-r参数,就会先在目标文件夹执行一次上传操作,然后再去缓存文件夹按照设定的每次上传文件的阈值大小复制文件。

python upload.py -t "/run/media/chocola/3T-DATA02/Projects/Pages/work/dist" -c "/run/media/chocola/3T-DATA02/Projects/Pages/work/tmp" -p "onimai-anime" --threshold 2048  -r

主程序

主程序主要就是调用这两个模块进行媒体库的处理和上传,以及在上传之前把静态的UI资源文件复制到目标文件夹并做好网页标题命名工作:

import argparse
import subprocess
import sys
import shutil
from pathlib import Path

# 导入同目录下的 upload 模块
import upload

def run_shell_command(cmd, ignore_error=False):
    """辅助函数:运行 shell 命令"""
    print(f"执行命令: {cmd}")
    try:
        subprocess.run(cmd, shell=True, check=True)
        return True
    except subprocess.CalledProcessError as e:
        if ignore_error:
            print(f"[提示] 命令未成功返回,但已设置为忽略 (可能仓库或相关配置已存在)。")
            return False
        else:
            print(f"\n[致命错误] 命令执行失败: {cmd}")
            sys.exit(1)

def main():
    parser = argparse.ArgumentParser(description="多媒体转换与自动分批部署脚手架")
    parser.add_argument("-s", "--source", required=True, help="目标文件夹目录 (需要转换的原始文件所在路径)")
    parser.add_argument("-w", "--workspace", required=True, help="临时处理文件夹目录 (工作区路径)")
    parser.add_argument("-p", "--project-name", required=True, help="远端 Cloudflare Pages 的项目/仓库名称")

    # 新增的可选参数:网页标题
    parser.add_argument("-t", "--title", help="自定义网页标题 (可选,例如: 某科学的超电磁炮)")

    # 可选参数,默认使用 512MB 阈值
    parser.add_argument("--threshold", type=int, default=512, help="单次上传阈值(MB),默认 512")
    args = parser.parse_args()

    source_dir = Path(args.source).resolve()
    workspace_dir = Path(args.workspace).resolve()

    if not source_dir.exists():
        print(f"[错误] 源目录不存在: {source_dir}")
        sys.exit(1)

    # 临时目录里的两个文件夹
    dist_dir = workspace_dir / "dist"  # 1. 转换兼上传的文件夹
    tmp_dir = workspace_dir / "tmp"    # 2. 用于中转的 tmp 文件夹

    # 确保目录结构存在
    dist_dir.mkdir(parents=True, exist_ok=True)
    tmp_dir.mkdir(parents=True, exist_ok=True)

    print("\n" + "="*50)
    print("🚀 第一阶段:格式转换与压缩 (调用 convert.py)")
    print("="*50)

    # 调用 convert.py
    convert_cmd = f'"{sys.executable}" convert.py "{source_dir}" "{dist_dir}"'
    run_shell_command(convert_cmd)

    print("\n" + "="*50)
    print("🚀 附加阶段:复制静态资源并设置网页标题")
    print("="*50)

    # 获取 static 文件夹路径(假设它与 main.py 在同级目录下)
    static_dir = Path(__file__).parent / "static"
    if not static_dir.exists():
        print(f"[警告] 找不到 static 目录: {static_dir},跳过静态资源复制。")
    else:
        print(f"正在复制 {static_dir} 下的所有文件到 {dist_dir} ...")
        # 复制 static 目录下的所有内容到 dist_dir (dirs_exist_ok=True 允许覆盖写入)
        shutil.copytree(static_dir, dist_dir, dirs_exist_ok=True)
        print("静态资源复制完成。")

        # 如果传入了自定义标题参数,则处理 index.html
        if args.title:
            index_html_path = dist_dir / "index.html"
            if index_html_path.exists():
                print(f"正在修改网页标题为: {args.title} - MediaPages")
                try:
                    with open(index_html_path, "r", encoding="utf-8") as f:
                        html_content = f.read()

                    old_title_tag = "<title>MediaPages</title>"
                    new_title_tag = f"<title>{args.title} - MediaPages</title>"

                    if old_title_tag in html_content:
                        html_content = html_content.replace(old_title_tag, new_title_tag)
                        with open(index_html_path, "w", encoding="utf-8") as f:
                            f.write(html_content)
                        print("✅ 标题修改成功!")
                    else:
                        print(f"[警告] 在 index.html 中未找到特定的 `{old_title_tag}` 标签,标题替换失败。")
                except Exception as e:
                    print(f"[错误] 修改 index.html 失败: {e}")
            else:
                print(f"[警告] 找不到 {index_html_path},无法修改标题。")
        else:
            print("未提供自定义标题参数 (-t),保留默认标题。")

    print("\n" + "="*50)
    print(f"🚀 第二阶段:创建远端仓库 [{args.project_name}]")
    print("="*50)

    # 创建 Cloudflare Pages 项目
    create_cmd = f"npx wrangler pages project create {args.project_name} --production-branch main"
    run_shell_command(create_cmd, ignore_error=True)

    print("\n" + "="*50)
    print("🚀 第三阶段:分批上传与部署 (调用 upload.py)")
    print("="*50)

    # 动态组装部署命令
    deploy_cmd = f"npx wrangler pages deploy {dist_dir} --project-name {args.project_name}"
    threshold_bytes = args.threshold * 1024 * 1024

    print(f"转换输出目录: {dist_dir}")
    print(f"中转缓存目录: {tmp_dir}")
    print(f"部署命令: {deploy_cmd}")

    # 调用修改后的 upload.py 主函数
    upload.run_deploy(
        target_dir=dist_dir,
        cache_dir=tmp_dir,
        bash_cmd=deploy_cmd,
        threshold=threshold_bytes
    )

    print("\n" + "="*50)
    print("🧹 第四阶段:清理临时工作区")
    print("="*50)

    # 直接将整个 tmp_dir 连带里面的所有空文件夹结构一起销毁
    if tmp_dir.exists():
        shutil.rmtree(tmp_dir)
        print(f"已彻底删除中转临时目录: {tmp_dir}")

    print("\n🎉 全部流程执行完毕!")

if __name__ == "__main__":
    main()

这个主程序的调用示例如下:

python main.py -s "/run/media/chocola/3T-DATA02/[Airota&VCB-Studio&LoliHouse] Toaru Kagaku no Railgun T [Ma10p_1080p]" -w "/run/media/chocola/3T-DATA02/Projects/Pages/work2" -p "railgun-t" -t "某科学的超电磁炮 T" --threshold 2048

其中的参数:

  • -s 上传的目标媒体库原始文件夹
  • -w 转化工作文件夹,用于存放转化后的文件夹和搬运的缓存文件夹
  • -p Cloudflare Pages 项目名称
  • --threshold 每次上传的大小,可选参数,默认512,单位为MB。

至此,开发的工作已经告一段落。

多项目顺序处理技巧

既然已经使用了这个程序,那想必你已经是有多个媒体库的了。人守着一直等待上传完成再去执行下一个命令肯定不合理,我这里给一个技巧——使用bash脚本
在程序工作目录创建一个.sh文件,内容参考下面的内容:

python main.py -s "/run/media/chocola/3T-DATA02/[Airota&VCB-Studio&LoliHouse] Toaru Kagaku no Railgun T [Ma10p_1080p]" -w "/run/media/chocola/3T-DATA02/Projects/Pages/work2" -p "railgun-t" -t "某科学的超电磁炮 T" --threshold 2048
python main.py -s "/run/media/chocola/3T-DATA02/[hyakuhuyu&VCB-Studio] Re Zero kara Hajimeru Isekai Seikatsu 3rd Season [Ma10p_1080p]" -w "/run/media/chocola/3T-DATA02/Projects/Pages/work3" -p "re0-3rd" -t "从零开始的异世界生活 第三季" --threshold 2048

全套代码获取

相关的代码我已经放在GitHub仓库:
github.com/Chocola-X/MediaPages
项目采用AGPLv3协议开源,但是程序生成的内容不受协议限制,可自由使用和部署。