UNPKG

@lzwme/m3u8-dl

Version:

Batch download of m3u8 files and convert to mp4

1,096 lines (1,037 loc) 55.9 kB
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="apple-mobile-web-app-capable" content="yes"> <title>M3U8 下载器</title> <link rel="icon" type="image/png" href="logo.png"> <link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" /> <link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/sweetalert2/11.16.1/sweetalert2.min.css" integrity="sha512-WnmDqbbAeHb7Put2nIAp7KNlnMup0FXVviOctducz1omuXB/hHK3s2vd3QLffK/CvvFUKrpioxdo+/Jo3k/xIw==" crossorigin="anonymous" referrerpolicy="no-referrer" /> <link rel="stylesheet" href="style.css?v={{version}}"> <script src="https://cdn.tailwindcss.com/3.4.16"></script> <script src="https://s4.zstatic.net/ajax/libs/vue/2.7.16/vue.min.js" integrity="sha512-Wx8niGbPNCD87mSuF0sBRytwW2+2ZFr7HwVDF8krCb3egstCc4oQfig+/cfg2OHd82KcUlOYxlSDAqdHqK5TCw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://s4.zstatic.net/ajax/libs/sweetalert2/11.16.1/sweetalert2.min.js" integrity="sha512-LGHBR+kJ5jZSIzhhdfytPoEHzgaYuTRifq9g5l6ja6/k9NAOsAi5dQh4zQF6JIRB8cAYxTRedERUF+97/KuivQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://s4.zstatic.net/ajax/libs/blueimp-md5/2.19.0/js/md5.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script> </head> <body> <div id="app" class="flex"> <button class="menu-toggle" @click="toggleSidebar"> <i class="fas" :class="sidebarCollapsed ? 'fa-bars' : 'fa-times'"></i> </button> <div class="sidebar p-4" :class="{ 'show': !sidebarCollapsed }"> <div class="mb-8"> <div class="flex items-center mb-4"> <img src="logo.png" alt="M3U8 下载器" class="w-8 h-8 mr-2"> <h1 class="text-xl font-bold text-gray-800">M3U8 下载器</h1> </div> <button @click="showNewDownloadDialog" class="w-full bg-blue-500 hover:bg-blue-600 text-white p-2 rounded-lg flex items-center justify-center"> <i class="fas fa-plus mr-2"></i>新建下载 </button> </div> <nav class="space-y-2"> <button @click="switchSection('download')" class="nav-item w-full p-3 rounded-lg flex items-center" :class="{ 'active': activeSection === 'download' }"> <i class="fas fa-download mr-3"></i>下载管理 </button> <button @click="switchSection('config')" class="nav-item w-full p-3 rounded-lg flex items-center" :class="{ 'active': activeSection === 'config' }"> <i class="fas fa-cog mr-3"></i>配置设置 </button> <button @click="switchSection('about')" class="nav-item w-full p-3 rounded-lg flex items-center" :class="{ 'active': activeSection === 'about' }"> <i class="fas fa-info-circle mr-3"></i>关于项目 </button> <!-- 跳转 ariang 链接 --> <button @click="switchSection('ariang')" class="nav-item w-full p-3 rounded-lg flex items-center"> <i class="fas fa-link mr-3"></i>Ariang </button> </nav> </div> <div class="main-content p-1 md:p-4" :style="{ marginLeft: sidebarCollapsed ? '0' : '16rem', width: sidebarCollapsed ? '100%' : 'calc(100% - 16rem)' }"> <div v-if="activeSection === 'download'" class="space-y-6"> <div class="bg-white rounded-lg shadow"> <div class="p-4"> <div class="flex justify-between items-center"> <h2 class="text-xl font-semibold">下载任务</h2> <div class="flex space-x-2"> <button @click="showNewDownloadDialog" class="px-3 py-1 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded"> <i class="fas fa-plus mr-1"></i>新建 </button> <button v-if="selectedTasks.length > 0" @click="pauseDownload(selectedTasks)" class="px-3 py-1 text-sm bg-yellow-500 text-white rounded hover:bg-yellow-600"> <i class="fas fa-pause mr-1"></i>暂停选中 </button> <button v-if="selectedTasks.length > 0" @click="resumeDownload(selectedTasks)" class="px-3 py-1 text-sm bg-green-500 text-white rounded hover:bg-green-600"> <i class="fas fa-play mr-1"></i>开始选中 </button> <button v-if="selectedTasks.length > 0" @click="deleteDownload(selectedTasks)" class="px-3 py-1 text-sm bg-red-500 text-white rounded hover:bg-red-600"> <i class="fas fa-trash mr-1"></i>删除选中 </button> <button v-if="selectedTasks.length === 0" @click="pauseDownload('all')" class="px-3 py-1 text-sm bg-yellow-500 text-white rounded hover:bg-yellow-600"> <i class="fas fa-pause mr-1"></i>全部暂停 </button> <button v-if="selectedTasks.length === 0" @click="resumeDownload('all')" class="px-3 py-1 text-sm bg-green-500 text-white rounded hover:bg-green-600"> <i class="fas fa-play mr-1"></i>全部开始 </button> <button v-if="selectedTasks.length === 0" @click="clearQueue" class="px-3 py-1 text-sm bg-red-500 text-white rounded hover:bg-red-600"> <i class="fas fa-trash mr-1"></i>清空队列 </button> </div> </div> <div class="mt-4 flex items-center space-x-4"> <div class="flex-1"> <div class="relative"> <input type="text" v-model="searchQuery" class="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-blue-500 focus:border-blue-500" placeholder="搜索任务名称或URL" aria-label="搜索任务"> <i class="fas fa-search absolute left-3 top-3 text-gray-400"></i> </div> </div> <div class="flex items-center space-x-2"> <select v-model="statusFilter" class="px-3 py-2 border rounded-lg focus:ring-blue-500 focus:border-blue-500" title="按状态筛选" aria-label="按状态筛选"> <option value="">全部状态</option> <option value="resume">下载中</option> <option value="pending">等待中</option> <option value="pause">已暂停</option> <option value="error">异常</option> <option value="done">已完成</option> </select> <button @click="clearFilters" class="px-3 py-2 text-gray-600 hover:text-gray-800" title="清除筛选条件" aria-label="清除筛选条件"> <i class="fas fa-times"></i> </button> </div> </div> </div> <div class="text-sm text-gray-600 border-b bg-gray-50"> <div class="flex items-center space-x-4 px-4 py-2"> <!-- 全选/反选复选框 --> <div class="flex items-center"> <input type="checkbox" :checked="selectedTasks.length === filteredTasks.length && filteredTasks.length > 0" :indeterminate.prop="selectedTasks.length > 0 && selectedTasks.length < filteredTasks.length" @change="toggleSelectAll" class="form-checkbox h-5 w-5 text-blue-500 rounded focus:ring-blue-500 mr-2" title="全选/反选"> <span class="text-gray-700 text-sm">全选/反选</span> <span class="ml-4 text-gray-400 text-xs">已选 {{selectedTasks.length}} / {{filteredTasks.length}}</span> </div> <div class="flex items-center"> <i class="fas fa-tasks text-blue-600 mr-1"></i> <span>总数: {{ filteredTasks.length }}</span> </div> <div class="flex items-center"> <i class="fas fa-clock text-yellow-500 mr-1"></i> <span>等待中: {{ queueStatus.queueLength }}</span> </div> <div class="flex items-center"> <i class="fas fa-download text-green-500 mr-1"></i> <span>下载中: {{ queueStatus.activeDownloads.length }}</span> </div> <!-- <div class="flex items-center"> <i class="fas fa-sliders-h text-blue-500 mr-1"></i> <span>最大并发: {{ queueStatus.maxConcurrent }}</span> </div> --> </div> </div> <div class="divide-y overflow-auto max-h-[calc(100vh-200px)]"> <div v-for="task in filteredTasks" :key="task.url" class="download-item p-4 hover:bg-gray-50 relative"> <div class="flex items-center justify-between mb-2"> <div class="flex-1"> <div class="flex items-center"> <input type="checkbox" :checked="selectedTasks.includes(task.url)" @change="toggleTaskSelection(task.url)" class="form-checkbox h-5 w-5 text-blue-500 rounded focus:ring-blue-500 mr-2" :title="'选择任务:' + task.showName"> <h3 class="font-bold text-green-600 truncate max-w-[calc(100vw-100px)]" :title="task.url"> {{ task.showName }} </h3> <div class="absolute right-1 top-1 text-xs rounded overflow-hidden"> <span v-if="task.status === 'pending'" class="px-2 py-0.5 bg-yellow-100 text-yellow-800">等待中</span> <span v-else-if="task.status === 'resume'" class="px-2 py-0.5 bg-green-100 text-green-800">下载中</span> <span v-else-if="task.status === 'pause'" class="px-2 py-0.5 bg-gray-100 text-gray-800">已暂停</span> <span v-else-if="task.status === 'done'" class="px-2 py-0.5 bg-blue-100 text-blue-800">已完成</span> <span v-else-if="task.status === 'error'" class="px-2 py-0.5 bg-red-100 text-red-600" :title="task.errmsg">{{task.errmsg || '异常'}} <i class="fas fa-info-circle"></i></span> </div> </div> <div class="flex items-center text-sm text-gray-500 mt-1 flex-wrap gap-2"> <span class="cursor-pointer text-blue-600 hover:text-blue-800" @click="showTaskDetail(task)"> <i class="fas fa-info-circle mr-1"></i> <span>详情</span> </span> <span v-if="config.showPreview" class="text-blue-500 hover:text-blue-600 cursor-pointer" @click="preview(task.url)"> <i class="fas fa-eye mr-1"></i>预览 </span> <span v-if="config.showLocalPlay" class="text-green-500 hover:text-green-600 cursor-pointer" @click="localPlay(task)"> <i class="fas fa-play-circle mr-1"></i>{{ task.localVideo ? '播放' : '边下边播' }} </span> <span v-if="task.duration" class="flex items-center"> <i class="fas fa-clock mr-1"></i> <span>时长: {{ T.formatTimeCost(task.duration * 1000) }}</span> </span><span class="flex items-center"> <i class="fas fa-file-video mr-1"></i> <span>大小: {{ T.formatSize(task.downloadedSize) }}</span> </span> <span v-if="task.tsCount" class="flex items-center"> <i class="fas fa-file-alt mr-1"></i> <span>分片: {{ task.tsSuccess + task.tsFailed }}/{{ task.tsCount }}</span> </span> <span v-if="task.status === 'resume'" class="flex items-center"> <i class="fas fa-hourglass-half mr-1"></i> <span>剩余: {{ T.formatTimeCost(task.remainingTime || 0) }}</span> </span> </div> </div> <div class="flex space-x-2"> <button v-if="task.status === 'resume' || task.status === 'pending'" @click="pauseDownload([task.url])" class="p-2 text-yellow-500 hover:bg-yellow-50 rounded" title="暂停"> <i class="fas fa-pause"></i> </button> <button v-if="task.status === 'pause' || task.status === 'error'" @click="resumeDownload([task.url])" class="p-2 text-green-500 hover:bg-green-50 rounded" title="继续"> <i class="fas fa-play"></i> </button> <button @click="deleteDownload([task.url])" class="p-2 text-red-500 hover:bg-red-50 rounded" title="删除"> <i class="fas fa-trash"></i> </button> </div> </div> <div class="relative pt-1"> <div class="flex mb-2 items-center justify-between"> <div class="flex items-center"> <span class="text-xs font-semibold inline-block text-blue-600"> {{ task.progress || 0 }}% </span> </div> <div> <span class="text-xs font-semibold inline-block py-1 px-2 rounded text-green-600"> <!-- <i class="fas fa-tachometer-alt mr-1"></i> --> {{ task.speedDesc }} </span> </div> </div> <div class="overflow-hidden h-2 text-xs flex rounded bg-blue-200"> <div :style="{ width: (task.progress || 0) + '%' }" class="progress-bar shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-blue-500"> </div> </div> </div> </div> <div v-if="filteredTasks.length === 0" class="p-8 text-center text-gray-500"> <i class="fas fa-download text-4xl mb-4"></i> <p>暂无下载任务</p> <button @click="showNewDownloadDialog" class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"> <i class="fas fa-plus mr-1"></i>添加下载任务 </button> </div> </div> </div> </div> <div v-if="activeSection === 'config'" class="bg-white rounded-lg shadow p-6 mb-6"> <h2 class="text-xl font-semibold mb-6">下载设置</h2> <form @submit.prevent="updateConfig" class="space-y-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <!-- 线程数 --> <div> <label class="block text-sm font-bold text-gray-700 mb-1">单任务并发下载线程数</label> <input v-model.number="config.threadNum" type="number" min="0" max="16" class="w-full p-2 border rounded-lg focus:ring-blue-500" placeholder=" 请输入线程数(1-16)" /> <p class="mt-1 text-sm text-gray-500">建议不超过 8 个,对单个服务器的并发请求数过多可能会导致被封 IP</p> </div> <!-- 最大并发下载数 --> <div> <label class="block text-sm font-bold text-gray-700 mb-1">最多同时下载视频数</label> <input v-model.number="config.maxDownloads" type="number" min="1" max="10" class="w-full p-2 border rounded-lg focus:ring-blue-500" placeholder="请输入最大并发下载数(1-10)" /> <p class="mt-1 text-sm text-gray-500">最多同时下载任务数量,默认为 3</p> </div> <!-- 保存目录 --> <div> <label class="block text-sm font-bold text-gray-700 mb-1">视频保存目录</label> <div class="flex"> <input v-model="config.saveDir" type="text" class="w-full p-2 border rounded-lg focus:ring-blue-500" placeholder="请输入保存目录路径" /> </div> <p class="mt-1 text-sm text-gray-500">默认为当前目录下 downloads 文件夹</p> </div> <!-- 删除缓存 --> <div> <label class="block text-sm font-bold text-gray-700 mb-1">下载完成后删除ts分片缓存</label> <div class="flex items-center mt-2"> <label class="inline-flex items-center"> <input type="checkbox" v-model="config.delCache" class="form-checkbox h-5 w-5 text-blue-500 rounded focus:ring-blue-500"> <span class="ml-2 text-gray-700">删除分片文件</span> </label> </div> <p class="mt-1 text-sm text-gray-500">保存临时文件可以在重复下载时识别缓存</p> </div> <!-- 转换格式 --> <div> <label class="block text-sm font-bold text-gray-700 mb-1">下载完成后转换格式</label> <div class="flex items-center mt-2"> <label class="inline-flex items-center"> <input type="checkbox" v-model="config.convert" class="form-checkbox h-5 w-5 text-blue-500 rounded focus:ring-blue-500"> <span class="ml-2 text-gray-700">合并转换为 MP4/TS 文件</span> </label> </div> </div> <!-- 显示预览按钮 --> <div> <label class="block text-sm font-bold text-gray-700 mb-1">显示预览按钮</label> <div class="flex items-center mt-2"> <label class="inline-flex items-center"> <input type="checkbox" v-model="config.showPreview" class="form-checkbox h-5 w-5 text-blue-500 rounded focus:ring-blue-500"> <span class="ml-2 text-gray-700">在下载列表中显示预览按钮</span> </label> </div> </div> <!-- 显示边下边播按钮 --> <div> <label class="block text-sm font-bold text-gray-700 mb-1">显示边下边播按钮</label> <div class="flex items-center mt-2"> <label class="inline-flex items-center"> <input type="checkbox" v-model="config.showLocalPlay" class="form-checkbox h-5 w-5 text-blue-500 rounded focus:ring-blue-500"> <span class="ml-2 text-gray-700">在下载列表中显示边下边播按钮</span> </label> </div> </div> </div> <div class="flex justify-end space-x-4"> <!-- <button type="button" @click="resetConfig" class="px-6 py-2 text-gray-600 hover:bg-gray-100 rounded-lg border"> 重置配置 </button> --> <button type="submit" class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg"> 保存配置 </button> </div> </form> </div> <div v-if="activeSection === 'config'" class="bg-white rounded-lg shadow p-6"> <h2 class="text-xl font-semibold mb-6">本地设置</h2> <form @submit.prevent="updateLocalConfig" class="space-y-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <!-- 访问密码 --> <div> <label class="block text-sm font-bold text-gray-700 mb-1">访问密码</label> <div class="flex"> <input v-model="token" type="password" maxlength="256" class="w-full p-2 border rounded-lg focus:ring-blue-500" placeholder="请输入访问密码" /> </div> <p class="mt-1 text-sm text-gray-500">若服务端设置了访问密码(token),请在此输入</p> </div> </div> <div class="flex justify-end space-x-4"> <!-- <button type="button" @click="resetConfig" class="px-6 py-2 text-gray-600 hover:bg-gray-100 rounded-lg border"> 重置配置 </button> --> <button type="submit" class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg"> 保存配置 </button> </div> </form> </div> <div v-if="activeSection === 'about'" class="bg-white rounded-lg shadow p-6"> <h2 class="text-xl font-semibold mb-6 text-center">关于项目</h2> <div class="space-y-6"> <div> <h3 class="text-lg font-medium text-green-700 mb-2">项目信息</h3> <div class="bg-gray-50 p-4 rounded-lg"> <p class="text-gray-600 mb-2"><strong>许可证:</strong>MIT</p> <p class="text-gray-600 mb-2"><strong>作者:</strong><a href="https://lzw.me" target="_blank" rel="noopener" class="text-blue-500 hover:text-blue-600">renxia</a (https://github.com/renxia)</p> <p class="text-gray-600 mb-2"><strong>GitHub:</strong> <a href="https://github.com/lzwme/m3u8-dl.git" target="_blank" rel="noopener" class="text-blue-500 hover:text-blue-600">https://github.com/lzwme/m3u8-dl.git</a> </p> <p class="text-gray-600"><strong>问题反馈:</strong> <a href="https://github.com/lzwme/m3u8-dl/issues" target="_blank" rel="noopener" class="text-blue-500 hover:text-blue-600"> https://github.com/lzwme/m3u8-dl/issues</a> </p> <p class="text-gray-600"><strong>当前版本:</strong> <a href="https://github.com/lzwme/m3u8-dl/release" target="_blank" rel="noopener" class="text-blue-500 hover:text-blue-600"> {{serverInfo.version}}</a> </p> <p class="text-gray-600"><strong>检测版本:</strong> <button @click="checkNewVersion" v-if="!serverInfo.appUpdateMessage" class="px-2 py-1 text-sm bg-green-600 hover:bg-green-700 text-white rounded"> <i class="fas fa-check mr-1"></i>检测新版本 </button> <span v-if="serverInfo.newVersion" class="text-blue-600">发现新版本![{{serverInfo.newVersion}}]</span> <span v-if="serverInfo.appUpdateMessage" class="text-green-600">{{serverInfo.appUpdateMessage}}</span> </p> </div> </div> <div> <h3 class="text-lg font-medium text-green-700 mb-2">项目简介</h3> <p class="text-gray-600"><a href="https://github.com/lzwme/m3u8-dl" target="_blank" rel="noopener" class="text-blue-500 hover:text-blue-600">@lzwme/m3u8-dl</a> 是一个功能强大的 m3u8 文件视频批量下载工具,支持多线程下载、边下边播、缓存续传等特性。</p> </div> <div> <h3 class="text-lg font-medium text-green-700 mb-2">主要特性</h3> <ul class="list-disc list-inside text-gray-600 space-y-2"> <li>多线程下载:采用线程池模式的多线程下载</li> <li>边下边播模式:支持使用已下载的 ts 缓存文件在线播放</li> <li>批量下载:支持指定多个 m3u8 地址批量下载</li> <li>缓存续传:下载失败会保留缓存,重试时只下载失败的片段</li> <li>加密支持:支持常见的 AES 加密视频流解密</li> <li>格式转换:支持自动转换为 mp4(需安装 ffmpeg)</li> <li>搜索功能:支持指定采集站标准 API,以命令行交互的方式搜索和下载</li> <li>WebUI:提供下载中心,支持启动为 webui 服务方式进行下载管理</li> </ul> </div> <div> <h3 class="text-lg font-medium text-green-700 mb-2">安装使用</h3> <div class="bg-gray-50 p-4 rounded-lg"> <p class="text-gray-600 mb-2">全局安装:</p> <pre class="bg-gray-800 text-gray-100 p-3 rounded-lg overflow-x-auto">npm i -g @lzwme/m3u8-dl<br>m3u8dl -h</pre> <p class="text-gray-600 mt-4 mb-2">使用 npx:</p> <pre class="bg-gray-800 text-gray-100 p-3 rounded-lg overflow-x-auto">npx @lzwme/m3u8-dl -h</pre> </div> </div> <div> <h3 class="text-lg font-medium text-green-700 mb-2">Docker 部署</h3> <div class="bg-gray-50 p-4 rounded-lg"> <p class="text-gray-600 mb-2">使用 Docker 命令运行:</p> <pre class="bg-gray-800 text-gray-100 p-3 rounded-lg overflow-x-auto">docker run -d --name m3u8-dl -p 6600:6600 -v ./downloads:/app/downloads -v ./cache:/app/cache lzwme/m3u8-dl</pre> <p class="text-gray-600 mt-4 mb-2">使用 docker-compose 运行:</p> <pre class="bg-gray-800 text-gray-100 p-3 rounded-lg overflow-x-auto">version: '3' services: m3u8-dl: image: lzwme/m3u8-dl container_name: m3u8-dl ports: - "6600:6600" volumes: - ./downloads:/app/downloads - ./cache:/app/cache restart: unless-stopped</pre> <p class="text-gray-600 mt-4">部署完成后,访问 <a href="http://localhost:6600" target="_blank" rel="noopener" class="text-blue-500 hover:text-blue-600">http://localhost:6600</a> 即可使用 WebUI 界面。</p> </div> </div> </div> </div> </div> </div> <div id="toast" class="grid fixed top-4 right-4 z-50 overflow-x-hidden overflow-y-auto"></div> <script> const T = { token: '', taskStatus: { resume: '下载中', pending: '等待中', pause: '已暂停', error: '异常', done: '已完成', }, reqHeaders: { 'content-type': 'application/json', authorization: localStorage.getItem('token') || '', }, initTJ() { if (!window._hmt) window._hmt = []; const hm = document.createElement("script"); hm.src = "https://hm.baidu.com/hm.js?0b21eda331ac9677a4c546dea88616d0"; const s = document.getElementsByTagName("script")[0]; s.parentNode.insertBefore(hm, s); }, request(method, url, data, headers = {}) { return fetch(url, { method, body: data ? JSON.stringify(data) : null, headers: { ...this.reqHeaders, ...headers } }).then(d => d.json()) .then(d => { if (d.code) T.toast(d.message || `[${url}] 请求失败`, { icon: 'error', duration: 10000 }); return d; }) .catch(e => { T.toast(`请求失败: ${e.message}`, { icon: 'error', duration: 10000 }); return { code: -1, message: e.message }; }); }, post(url, data) { return this.request('POST', url, data); }, get(url) { return this.request('GET', url); }, alert(msg, p) { p = typeof msg === 'object' ? msg : Object.assign({ text: msg }, p); if (!p.toast) p.allowOutsideClick = false; return Swal.fire(Object.assign({ icon: 'info', showConfirmButton: false, showCloseButton: true, confirmButtonText: '确定', cancelButtonText: '关闭' }, p)); }, confirm(msg, p) { return this.alert(msg, { showConfirmButton: true, showCancelButton: true, showCloseButton: true, confirmButtonText: '确认', cancelButtonText: '取消' }); }, toast(msg, p) { p = (typeof msg === 'object' ? msg : Object.assign({ text: msg }, p)); const config = Object.assign({ type: p.icon || 'success', duration: p.timer || 3000 }, p); const toast = document.createElement('div'); const iconMap = { success: 'fa-check-circle text-green-600', error: 'fa-times-circle text-red-600', warning: 'fa-exclamation-triangle text-yellow-600', info: 'fa-info-circle text-blue-500' }; toast.className = `custom-toast custom-toast-${config.type}`; toast.innerHTML = [ `<i class="custom-toast-icon mr-2 fa ${iconMap[config.type] || iconMap.info}"></i>`, `<span class="break-all">${config.text || ''}</span>`, `<i class="custom-toast-close fa fa-close fixed right-2 cursor-pointer text-lg" onclick="this.parentElement.remove()"></i>` ].join(''); document.body.appendChild(toast); setTimeout(() => toast.classList.add('show'), 10); const close = () => { toast.classList.remove('show'); toast.classList.add('hide'); setTimeout(() => toast.remove(), 300); }; if (config.duration > 0) setTimeout(() => close(), config.duration); return { element: toast, close }; }, // 格式化大小 formatSize: function (size) { if (size < 1024) { return size + ' B'; } else if (size < 1024 * 1024) { return (size / 1024).toFixed(2) + ' KB'; } else if (size < 1024 * 1024 * 1024) { return (size / (1024 * 1024)).toFixed(2) + ' MB'; } else { return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } }, // 格式化速度 formatSpeed: function (speed) { if (speed < 1024) { return speed + ' B/s'; } else if (speed < 1024 * 1024) { return (speed / 1024).toFixed(2) + ' KB/s'; } else if (speed < 1024 * 1024 * 1024) { return (speed / (1024 * 1024)).toFixed(2) + ' MB/s'; } else { return (speed / (1024 * 1024 * 1024)).toFixed(2) + ' GB/s'; } }, // 格式化时间 formatTimeCost: function (seconds) { seconds /= 1000; if (seconds < 60) { return seconds + '秒'; } else if (seconds < 60 * 60) { return (seconds / 60).toFixed(2) + '分钟'; } else if (seconds < 60 * 60 * 24) { return (seconds / (60 * 60)).toFixed(2) + '小时'; } else { return (seconds / (60 * 60 * 24)).toFixed(2) + '天'; } }, safeJSONParse(data) { try { return JSON.parse(data); } catch (error) { console.error('解析 JSON 失败:', data, error); return null; } } }; Vue.prototype.T = T; T.initTJ(); window.APP = new Vue({ el: '#app', data: { ws: null, serverInfo: { version: '{{version}}', ariang: false, newVersion: '', appUpdateMessage: '', }, config: { /** 并发下载线程数。取决于服务器限制,过多可能会容易下载失败。一般建议不超过 8 个。默认为 cpu数 * 2,但不超过 8 */ threadNum: 0, /** 下载文件保存的路径。默认为当前目录 */ saveDir: '', /** 下载成功后是否删除临时文件。默认为 true。保存临时文件可以在重复下载时识别缓存 */ delCache: true, /** 下载完毕后,是否合并转换为 mp4 或 ts 文件。默认为 true */ convert: true, /** 是否显示预览按钮 */ showPreview: true, /** 是否显示边下边播按钮 */ showLocalPlay: true, /** 最大并发下载数 */ maxDownloads: 3, }, /** 访问密码(token) */ token: T.reqHeaders.authorization, /** 下载任务列表,以 url 为 key */ tasks: {}, /** 选中的任务列表 */ selectedTasks: [], /** 队列状态 */ queueStatus: { queueLength: 0, activeDownloads: [], maxConcurrent: 5 }, activeSection: 'download', sidebarCollapsed: window.innerWidth <= 768, /** 搜索关键词 */ searchQuery: '', /** 状态筛选 */ statusFilter: '', /** 最近一次执行 ui update 的时间 */ forceUpdateTime: 0, }, computed: { /** 过滤后的任务列表 */ filteredTasks: function () { let tasks = Object.values(this.tasks); // 搜索过滤 if (this.searchQuery) { const query = this.searchQuery.toLowerCase(); tasks = tasks.filter(task => { const filename = (task.localVideo || task.filename || task.dlOptions?.filename || task.url).toLowerCase(); return filename.includes(query) || task.url.toLowerCase().includes(query); }); } // 状态过滤 if (this.statusFilter) { tasks = tasks.filter(task => task.status === this.statusFilter); } // 排序:resume > pending > pause > error > done const statusOrder = { resume: 0, pending: 1, pause: 2, error: 3, done: 4 }; tasks.sort((a, b) => (statusOrder[a.status] - statusOrder[b.status]) || (a.status === 'done' ? (b.filename - a.filename) : ((a.startTime || 0) - (b.startTime || 0)))); // 更新 queueStatus const queueStatus = { queueLength: 0, activeDownloads: [], maxConcurrent: this.config.maxDownloads, }; tasks.forEach(task => { task.showName = task.filename || task.dlOptions?.filename || task.localVideo || task.url; if (task.status === 'pending') { queueStatus.queueLength++; } else if (task.status === 'resume') { queueStatus.activeDownloads.push(task.url); } }); this.queueStatus = queueStatus; return tasks; } }, methods: { initEventsForApp() { if (!window.electron) return; const ipc = window.electron.ipc; ipc.on('message', (ev) => { if (typeof ev.data === 'string') T.toast(ev.data, { icon: 'info' }); else console.log(ev.data); }); ipc.on('downloadProgress', (data) => { console.log('downloadProgress', data); this.serverInfo.appUpdateMessage = `下载中:${Number(data.percent).toFixed(2)}% [${T.formatSpeed(data.bytesPerSecond)}] [${T.formatSize(data.transferred)}/${T.formatSize(data.total)}]`; }); }, async checkNewVersion() { try { const r = await fetch(`https://registry.npmmirror.com/@lzwme/m3u8-dl/latest`).then(r => r.json()); if (r.version) { if (r.version === this.serverInfo.version) T.toast(`已是最新版本,无需更新[${r.version}]`); else { this.serverInfo.newVersion = r.version; if (window.electron) { window.electron.ipc.send('checkForUpdate'); } else { T.alert(`发现新版本[${r.version}],请前往 https://github.com/lzwme/m3u8-dl/releases 下载最新版本`, { icon: 'success' }); } } } } catch (error) { console.error('检查新版本失败:', error); T.alert(`版本检查失败:${error.message}`, { icon: 'error' }); } }, forceUpdate: function () { const now = Date.now(); if (now - this.forceUpdateTime > 500) { this.forceUpdateTime = now; this.tasks = { ...this.tasks }; this.$forceUpdate(); } else { if (this.forceUpdateTimeout) clearTimeout(this.forceUpdateTimeout); this.forceUpdateTimeout = setTimeout(() => this.forceUpdate(), 500); } }, wsConnect: function (reconnectDelay = 3000) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.close(); } const ws = new WebSocket(`ws://${location.host}/ws?token=${this.token}`); this.ws = ws; ws.onmessage = (e) => { let { type, data } = T.safeJSONParse(e.data); switch (type) { case 'serverInfo': Object.assign(this.serverInfo, data); break; case 'tasks': this.tasks = data; break; case 'progress': if (!Array.isArray(data)) data = [data]; this.$nextTick(() => { data.forEach(item => item.url && (this.tasks[item.url] = item)); this.forceUpdate(); }); break; case 'delete': if (Array.isArray(data)) { data.forEach(url => delete this.tasks[url]); this.forceUpdate(); } break; case 'queueStatus': this.queueStatus = data; break; } }; ws.onopen = () => { T.toast('ws连接成功', { icon: 'success' }); }; ws.onclose = (ev) => { console.error('ws连接关闭:', ev.code, ev.reason); if (ev.code === 1008) { return T.alert('未授权或密码不正确,请在设置界面输入正确的访问密码', { icon: 'error' }); } T.toast(`连接已断开,${reconnectDelay / 1000}s 后将重试...`, { icon: 'error' }); setTimeout(() => { this.wsConnect(reconnectDelay + 1000); }, reconnectDelay); }; }, /** 获取配置 */ fetchConfig: async function () { const config = await T.get('/api/config'); if (config.code) { console.error('获取配置失败:', config); T.alert('获取配置失败: ' + config.message, { icon: 'error' }); return false; } for (const key in config) { if (key in this.config) this.config[key] = config[key]; } return true; }, /** 更新配置 */ updateConfig: async function () { const result = await T.post('/api/config', this.config); T.toast(result.message || '配置已更新', { icon: result.code ? 'error' : 'success' }); }, updateLocalConfig: async function () { this.updateToken(); }, updateToken() { const isUpdated = this.token !== T.reqHeaders.authorization; if (!isUpdated) return; if (this.token) this.token = md5(this.token).slice(0, 8); T.reqHeaders.authorization = this.token || ''; if (this.token) { localStorage.setItem('token', this.token); this.fetchConfig().then(d => d && this.wsConnect()); } else { localStorage.removeItem('token'); } }, /** 显示新建下载弹窗 */ showNewDownloadDialog() { Swal.fire({ title: '新建下载', width: '900px', html: ` <div class="text-left"> <div class="flex flex-row gap-4"> <input type="text" id="playUrl" placeholder="[实验性]输入列表页或播放页地址,提取m3u8链接" autocomplete="off" id="urlInput" class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400" value=""> <div class="flex flex-row gap-1"> <button type="button" id="getM3u8UrlsBtn" class="player-btn px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none"> 提取 </button> </div> </div> <div class="mt-4"> <div class="flex flex-row gap-2 items-center"> <input id="subUrlRegex" class="flex-1 p-2 border rounded-lg focus:border-blue-500" placeholder="[实验性](可选)播放页链接特征规则"> </div> <p class="ml-2 mt-1 text-sm text-gray-500">用于从视频列表页准确识别播放地址。如:<code>play/845-1-</code></p> </div> <div class="mt-4"> <label class="block text-sm font-bold text-gray-700 mb-1">视频链接(每行一个,支持m3u8地址及抖音、微博、皮皮虾视频分享链接)</label> <textarea id="downloadUrls" class="w-full p-2 border rounded-lg focus:ring-blue-500" rows="3" placeholder="格式: URL | 名称(可选)、名称 | URL"></textarea> </div> <div class="mt-4"> <div class="flex flex-row gap-2 items-center"> <label class="block text-sm font-bold text-gray-700 mb-1">视频名称</label> <input id="filename" class="flex-1 p-2 border rounded-lg focus:border-blue-500" placeholder="请输入视频名称(可选)"> </div> <p class="ml-2 mt-1 text-sm text-gray-500">若输入多个链接,将依次以"视频名称+第N集"命名</p> </div> <div class="mt-4 flex flex-row gap-2 items-center"> <label class="block text-sm font-bold text-gray-700 mb-1">保存位置</label> <input id="saveDir" class="flex-1 p-2 border rounded-lg focus:ring-blue-500" placeholder="请输入保存路径" value="${this.config.saveDir || ''}"> </div> <div class="mt-4"> <label class="block text-sm font-bold text-gray-700 mb-1">删除时间片段(适用于移除广告片段的情况)</label> <input id="ignoreSegments" class="w-full p-2 border rounded-lg focus:ring-blue-500" placeholder="以-分割起止时间,多个以逗号分隔。示例:0-10,20-100"> </div> <div class="mt-4"> <label class="block text-sm font-bold text-gray-700 mb-1">自定义请求头</label> <textarea id="headers" class="w-full p-2 border rounded-lg focus:ring-blue-500" rows="3" placeholder="每行一个(微博视频须设置 Cookie),格式:Key: Value。例如:&#10;Referer: https://example.com&#10;Cookie: token=123"></textarea> </div> </div> `, showCancelButton: true, confirmButtonText: '开始下载', cancelButtonText: '取消', focusConfirm: false, showCloseButton: true, allowOutsideClick: false, preConfirm: () => { const urlsText = document.getElementById('downloadUrls').value.trim(); const filename = document.getElementById('filename').value.trim(); let saveDir = document.getElementById('saveDir').value.trim(); const headers = document.getElementById('headers').value.trim(); const ignoreSegments = document.getElementById('ignoreSegments').value.trim(); if (!urlsText) { Swal.showValidationMessage('请输入至少一个 M3U8 链接'); return false; } // 解析链接和文件名 const urls = urlsText.split('\n').map(line => { let [url, name = ''] = line.split(/[\s|$]+/).map(s => s.trim()); if (name.startsWith('http')) [name, url] = [url, name]; return { url, name }; }).filter(item => item.url.startsWith('http')); // 验证链接格式 if (!urls.length) { Swal.showValidationMessage('请输入正确的 M3U8 链接'); return false; } if (urls.length > 1 && filename && !saveDir.includes(filename)) { if (!saveDir) saveDir = this.config.saveDir; saveDir = saveDir.replace(/\/?$/, '') + '/' + filename; } return urls.map((item, idx) => ({ url: item.url, filename: item.name || (filename ? `${filename}${urls.length > 1 ? `第${idx + 1}集` : ''}` : ''), saveDir, headers, ignoreSegments, })); } }).then((result) => { if (result.isConfirmed) this.startBatchDownload(result.value); }); setTimeout(() => { const btn = document.getElementById('getM3u8UrlsBtn'); if (!btn) return; btn.addEventListener('click', async () => { const url = document.getElementById('playUrl').value.trim(); if (!/^https?:/.test(url)) { return Swal.showValidationMessage('请输入正确的 URL 地址'); } btn.setAttribute('disabled', 'disabled'); btn.innerText = '解析中...'; const headers = document.getElementById('headers').value.trim(); const subUrlRegex = document.getElementById('subUrlRegex').value.trim(); T.post('/api/getM3u8Urls', { url, headers, subUrlRegex }).then(r => { if (Array.isArray(r.data)) { document.getElementById('downloadUrls').value = r.data.map(d => d.join(' | ')).join('\n'); T.toast(r.message || `解析完成!获取到 ${r.data.length} 个地址`); } btn.removeAttribute('disabled'); btn.innerText = '提取'; }); }); }, 500); }, /** 批量下载 */ startBatchDownload: async function (list) { try { list.forEach(async (item, idx) => { Object.entries(item).forEach(([key, value]) => !value && delete item[key]); if (!/\.html?$/.test(item.url)) this.tasks[item.url] = { status: 'resume', progress: 0, speed: 0, remainingTime: 0, size: 0 }; }); const r = await T.post('/api/download', { list }); if (!r.code) T.toast(r.message || '批量下载已开始'); this.forceUpdate(); } catch (error) { console.error('批量下载失败:', error); T.alert('下载失败: ' + error.message, { icon: 'error' }); } }, /** 暂停下载 */ pauseDownload: async function (urls) { if (!urls) urls = this.selectedTasks; if (typeof urls === 'string') urls = [urls]; const r = await T.post('/api/pause', { urls, all: urls[0] === 'all' }); if (!r.code) T.toast(r.message || '已暂停下载'); if (urls === this.selectedTasks) this.selectedTasks = []; }, /** 恢复下载 */ resumeDownload: async function (urls) { if (!urls) urls = this.selectedTasks; if (typeof urls === 'string') urls = [urls]; const r = await T.post('/api/resume', { urls, all: urls[0] === 'all' }); if (!r.code) T.toast(r.message || '已恢复下载'); if (urls === this.selectedTasks) this.selectedTasks = []; }, /** 删除选中的任务 */ async deleteDownload(urls = this.selectedTasks) { if (!urls.length) return; try { const result = await Swal.fire({ title: '确认删除', html: ` <div class="text-left"> <div class="mb-4"> <label class="inline-flex items-center"> <input type="checkbox" id="deleteCache" class="form-checkbox h-5 w-5 text-blue-500 rounded focus:ring-blue-500" checked> <span class="ml-2 text-gray-700">同时删除已下载的缓存</span> </label> </div> <div> <label class="inline-flex items-center"> <input type="checkbox" id="deleteVideo" class="form-checkbox h-5 w-5 text-blue-500 rounded focus:ring-blue-500" checked> <span class="ml-2 text-red-700">同时删除已下载的视频</span> </label> </div> </div> `, showCancelButton: true, confirmButtonText: '确认删除', cancelButtonText: '取消', confirmButtonColor: '#ef4444', focusConfirm: false, preConfirm: () => { return { deleteCache: document.getElementById('deleteCache').checked, deleteVideo: document.getElementById('deleteVideo').checked }; } }); if (result.isConfirmed) { const r = await T.post('/api/delete', { urls, deleteCache: result.value.deleteCache, deleteVideo: result.value.deleteVideo }); if (!r.code) { T.toast(r.message || '已删除选中的下载'); urls.forEach(url => (delete this.tasks[url])); if (urls === this.selectedTasks) this.selectedTasks = []; this.forceUpdate(); } } } catch (error) { console.error('删除下载失败:', error); T.alert('删除下载失败: ' + error.message); } }, getTasks: async function () { this.tasks = await T.get('/api/tasks'); }, showTaskDetail(task) { console.log(task); const isResume = task.status === 'resume'; const taskInfo = { 名称: task.filename || task.localVideo, 状态: T.taskStatus[task.status] || task.status, 大小: `${T.formatSize(task.downloadedSize || 0)} / ${task.size ? T.formatSize(task.size) : ''}`, 分片: task.tsCount ? `<span class=text-green-600>${task.tsSuccess}</span> / <span class=text-red-500>${task.tsFailed}</span> / ${task.tsCount}` : '-', 进度: `${task.progress || '-'}%`, 平均速度: `${task.avgSpeedDesc || '-'}/s`, 并发线程: task.threadNum, 下载地址: task.url, 保存位置: task.localVideo || task.options?.saveDir, 开始时间: task.startTime && new Date(task.startTime).toLocaleString(), 结束时间: !isResume && task.endTime && new Date(task.endTime).toLocaleString(), 预估还需: isResume && task.remainingTime && T.formatTimeCost(task.remainingTime), 相关信息: task.errmsg && `<span class=text-red-600>${task.errmsg}</span>` }; T.alert({ title: '任务详情', width: 1000, icon: '', html: [ '<div class="flex-col full-width text-left">', Object.entries(taskInfo).filter(d => d[1]).map( ([key, value]) => `<div class="flex"><label class="font-bold text-right inline-block" style="min-width:80px">${key}:</label> <span class="ml-2 text-gray-400 word-break">${value}</span></div>` ).join(''), '</div>' ].join(''), }) }, /** 边下边播 */ localPlay: function (task) { const filepath = task.localVideo || task.localM3u8; const url = location.origin + `/localplay/${encodeURIComponent(filepath)}`; console.log(task); Swal.fire({ title: task?.options.filename || task.url, width: '1000px', padding: 0, allowOutsideClick: false, showCloseButton: true, showConfirmButton: false, html: `<iframe src="./play.html?url=${encodeURIComponent(url)}" style="width: 100%; height: 550px; max-height: 90vh" frameborder="0" allowfullscreen></iframe>`, }); }, preview: function (url) { window.open(`https://lzw.me/x/m3u8-player/?url=${encodeURIComponent(url)}`); }, // 切换侧栏状态 toggleSidebar() { this.sidebarCollapsed = !this.sidebarCollapsed; },