yt-dlp非常强大,有了它youtube上的视频基本上可以自由下载。但每次手敲命令也着实麻烦。GPT在手,那就手搓一个web 版吧。

  1. 准备好:(wget yt-dlp )
  2. 准备好:ffmpeg (apt install ffmpeg)
  3. 准备好 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>

可以自由下载了

在音频列表中选一个音频格式,在视频列表中选一个视频格式,这两个匹配。点下载,就合并一个新的视频了。
image
image-1733060177310