yt-dlp非常强大,有了它youtube上的视频基本上可以自由下载。但每次手敲命令也着实麻烦。GPT在手,那就手搓一个web 版吧。
- 准备好:(wget yt-dlp )
- 准备好:ffmpeg (apt install ffmpeg)
- 准备好 node (apt install nodejs)
以上准备好,在终端能直接调用就行,后端完成后。前端交互,准备一个input 用来存放用户输入视频链接,后面加个buton 分析,用来请求后端的analyze 接口,实现执行yt-dlp -J 的命令输出视频json数据,把这个json音视频的格式列表数据返回并展示在界面上,用户在界面上手动指定格式组合后,再调用后端的下载接口,根据音视频的format_id 合并一个下载文件,然后返回给前端。
后端 server.js
const express = require('express');
const { exec } = require('child_process');
const path = require('path');
const bodyParser = require('body-parser');
const cors = require('cors'); // Import CORS
const fs = require('fs');
const app = express();
// Enable CORS for all origins
app.use(cors());
// Middleware to parse JSON requests
app.use(bodyParser.json());
// Endpoint to analyze the video URL
app.post('/analyze', (req, res) => {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
const command = `./yt-dlp -J ${url}`;
exec(command, (err, stdout, stderr) => {
if (err) {
return res.status(500).json({ error: `Error executing command: ${stderr || err.message}` });
}
try {
const data = JSON.parse(stdout);
// Filter formats, keeping only mp4 or m4a
const filteredFormats = data.formats.filter(format => format.ext === 'mp4' || format.ext === 'm4a');
// Return filtered formats
data.formats = filteredFormats;
res.json(data);
} catch (parseError) {
res.status(500).json({ error: 'Error parsing yt-dlp output' });
}
});
});
// Endpoint to start the download
app.post('/download', (req, res) => {
const { url, audio, video, title } = req.body;
if (!url || !audio || !video) {
return res.status(400).json({ error: 'URL, audio, and video are required' });
}
// Use yt-dlp to extract the filename
const getFileNameCommand = `./yt-dlp --get-filename -f ${audio}+${video} ${url}`;
exec(getFileNameCommand, (err, stdout, stderr) => {
if (err) {
return res.status(500).json({ error: `Error extracting filename: ${stderr || err.message}` });
}
// If yt-dlp does not return a filename, fallback to the provided title
let filename = stdout.trim().replace(/[\r\n]+/g, ''); // Remove any newlines from filename
if (!filename) {
filename = title || "untitled_video";
}
// Clean the filename to avoid illegal characters and ensure it fits within system limits
const sanitizedFilename = filename
.replace(/[\\\/:*?"<>|]/g, '_') // Replace illegal characters with _
.substring(0, 200); // Limit filename length to prevent path overflow
// Create the download path, use the audio and video IDs to uniquely identify the file
const downloadPath = path.join(__dirname, 'downloads', `${sanitizedFilename}_${audio}_${video}.mp4`);
// Check if the file already exists
if (fs.existsSync(downloadPath)) {
return res.download(downloadPath, `${sanitizedFilename}_${audio}_${video}.mp4`);
}
// Run yt-dlp to download the video
const downloadCommand = `./yt-dlp -f ${audio}+${video} -o "${downloadPath}" ${url}`;
exec(downloadCommand, (err, stdout, stderr) => {
if (err) {
return res.status(500).json({ error: `Error executing download: ${stderr || err.message}` });
}
// Send the file to the user
res.download(downloadPath, `${sanitizedFilename}_${audio}_${video}.mp4`, (downloadError) => {
if (downloadError) {
res.status(500).json({ error: 'Error downloading file' });
}
});
});
});
});
// Start the server
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
前端单页 app.vue
<template>
<div id="app">
<h1>油管视频下载器</h1>
<input v-model="url" placeholder="输入视频URL" />
<button @click="analyze" :disabled="isAnalyzing || isDownloading">分析</button>
<div v-if="formats.length > 0">
<h2>选择格式</h2>
<!-- 音频格式选择 -->
<h3>音频格式</h3>
<select v-model="selectedAudioId" @change="onAudioSelectionChange" :disabled="isDownloading">
<option value="" disabled>请选择音频</option>
<option v-for="format in audioFormats" :key="format.format_id" :value="format.format_id">
{{ format.format_note }} ({{ format.ext }}) - {{ format.filesize_approx ? Math.floor(format.filesize_approx / 1024 / 1024) + ' MB' : 'N/A' }} - {{ format.resolution }} - {{ format.abr }} kbps
</option>
</select>
<p v-if="selectedAudioId">已选择音频格式: {{ getSelectedAudioFormat }}</p>
<!-- 音频格式显示表格 -->
<table v-if="selectedAudioId && audioFormats.length > 0" border="1" cellpadding="10" cellspacing="0">
<thead>
<tr>
<th>选择</th>
<th>格式</th>
<th>扩展名</th>
<th>文件大小</th>
<th>分辨率</th>
<th>比特率</th>
</tr>
</thead>
<tbody>
<tr v-for="format in audioFormats" :key="format.format_id">
<td><input type="checkbox" v-model="selectedAudioFormatIds" :value="format.format_id" /></td>
<td>{{ format.format_note }} ({{ format.ext }})</td>
<td>{{ format.ext }}</td>
<td>{{ format.filesize_approx ? Math.floor(format.filesize_approx / 1024 / 1024) + ' MB' : 'N/A' }}</td>
<td>{{ format.resolution }}</td>
<td>{{ format.abr }} kbps</td>
</tr>
</tbody>
</table>
<!-- 视频格式选择 -->
<h3>视频格式</h3>
<select v-model="selectedVideoId" @change="onVideoSelectionChange" :disabled="isDownloading">
<option value="" disabled>请选择视频</option>
<option v-for="format in videoFormats" :key="format.format_id" :value="format.format_id">
{{ format.format_note }} ({{ format.ext }}) - {{ format.filesize_approx ? Math.floor(format.filesize_approx / 1024 / 1024) + ' MB' : 'N/A' }} - {{ format.resolution }} - {{ format.tbr }} kbps
</option>
</select>
<p v-if="selectedVideoId">已选择视频格式: {{ getSelectedVideoFormat }}</p>
<!-- 视频格式显示表格 -->
<table v-if="selectedVideoId && videoFormats.length > 0" border="1" cellpadding="10" cellspacing="0">
<thead>
<tr>
<th>选择</th>
<th>格式</th>
<th>扩展名</th>
<th>文件大小</th>
<th>分辨率</th>
<th>比特率</th>
</tr>
</thead>
<tbody>
<tr v-for="format in videoFormats" :key="format.format_id">
<td><input type="checkbox" v-model="selectedVideoFormatIds" :value="format.format_id" /></td>
<td>{{ format.format_note }} ({{ format.ext }})</td>
<td>{{ format.ext }}</td>
<td>{{ format.filesize_approx ? Math.floor(format.filesize_approx / 1024 / 1024) + ' MB' : 'N/A' }}</td>
<td>{{ format.resolution }}</td>
<td>{{ format.tbr }} kbps</td>
</tr>
</tbody>
</table>
<!-- 下载按钮 -->
<button @click="download" :disabled="isDownloading || !selectedAudioFormatIds.length || !selectedVideoFormatIds.length">下载</button>
</div>
<!-- 加载指示器 -->
<div v-if="isAnalyzing" class="loading">分析中...</div>
<div v-if="isDownloading" class="loading">下载中...</div>
</div>
</template>
<script>
import axios from "axios";
export default {
data() {
return {
url: "", // 输入的视频URL
formats: [], // 存储格式列表(音频和视频)
selectedAudioId: null, // 选中的音频ID
selectedVideoId: null, // 选中的视频ID
selectedAudioFormatIds: [], // 选中的音频ID集合
selectedVideoFormatIds: [], // 选中的视频ID集合
isAnalyzing: false, // 分析状态
isDownloading: false, // 下载状态
audioFormats: [], // 存储音频格式
videoFormats: [], // 存储视频格式
videoTitle:"",
};
},
computed: {
getSelectedAudioFormat() {
return this.audioFormats.find(f => f.format_id === this.selectedAudioId).format;
},
getSelectedVideoFormat() {
return this.videoFormats.find(f => f.format_id === this.selectedVideoId).format;
}
},
methods: {
async analyze() {
if (!this.url) return;
this.isAnalyzing = true;
try {
const response = await axios.post("http://localhost:3000/analyze", { url: this.url });
const data = response.data;
this.formats = data.formats || [];
// 分离音频和视频格式
this.audioFormats = this.formats.filter(f => f.audio_ext && f.audio_ext !== 'none');
this.videoFormats = this.formats.filter(f => f.video_ext && f.video_ext !== 'none');
this.videoTitle = data.title.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_');
} catch (error) {
alert("分析失败:" + error.message);
} finally {
this.isAnalyzing = false;
}
},
// 修改 download 方法
async download() {
if (!this.selectedAudioId || !this.selectedVideoId) {
alert("请选择音频和视频格式!");
return;
}
const params = {
url: this.url,
audio: this.selectedAudioId, // 发送选中的音频ID
video: this.selectedVideoId, // 发送选中的视频ID
title: this.url, // 这里可以提供一个自定义的标题,作为后端下载文件名的备选方案
};
this.isDownloading = true;
try {
// 发送下载请求
const response = await axios.post("http://localhost:3000/download", params, {
responseType: "blob",
});
// 从响应头中获取文件名
const contentDisposition = response.headers['content-disposition'];
let fileName = "video.mp4"; // 默认文件名
if (contentDisposition) {
const matches = contentDisposition.match(/filename="(.+)"/);
if (matches && matches[1]) {
fileName = matches[1];
}
}
// 如果文件名为 video.mp4,使用视频的标题,并去除无效字符
if (fileName === "video.mp4" && this.videoTitle.length>0) {
fileName =this.videoTitle + ".mp4";
}
// 创建下载链接并下载文件
const blob = new Blob([response.data], { type: "video/mp4" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = fileName; // 使用响应头中获取的文件名
link.click();
} catch (error) {
alert("下载失败:" + error.message);
} finally {
this.isDownloading = false;
}
},
}
};
</script>
<style scoped>
#app {
font-family: Arial, sans-serif;
text-align: center;
margin-top: 20px;
}
input {
margin: 10px 0;
}
button {
margin: 10px;
}
.loading {
font-size: 18px;
color: #999;
margin-top: 20px;
}
select {
width: 200px;
padding: 5px;
margin: 5px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 10px;
text-align: left;
}
th {
background-color: #f4f4f4;
font-weight: bold;
}
td {
border: 1px solid #ddd;
}
ul {
list-style-type: none;
}
li {
margin: 5px 0;
}
</style>
可以自由下载了
在音频列表中选一个音频格式,在视频列表中选一个视频格式,这两个匹配。点下载,就合并一个新的视频了。