@lzwme/m3u8-dl
Version:
Batch download of m3u8 files and convert to mp4
1,096 lines (1,037 loc) • 55.9 kB
HTML
<!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。例如: Referer: https://example.com 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;
},