bilibili_Api_Player:基于 Express 的 B站视频处理接口项目

Source repository mirror for OpenHarmony Bench release snapshot.

分支1Tags0
当前项目代码仓暂无内容

const express = require('express'); const router = express.Router(); const authorize = require("../auth/authUtils"); // 您的授权中间件 const { checkDailyDownloadLimit } = require('./videoUtils'); const redis = require('../../config/redis');

/**

  • @api {get} /api/video/daily-limit-status

  • @description 查询用户当前的每日下载申请限制状态

  • @access Protected - 需要用户登录 */ router.get('/daily-limit-status', authorize(['1', '2', '3', '4']), async (req, res) => { try { const userId = req.user.uid || req.user.id; const userRole = req.user.role;

    // 获取今日申请次数 const today = new Date().toISOString().split('T')[0]; const redisKey = download_requests:${userId}:${today}; const currentCount = parseInt(await redis.get(redisKey) || 0);

    // 检查限制状态 const limitStatus = await checkDailyDownloadLimit(userId, userRole, redis);

    // 根据权限等级设置每日限制 const dailyLimits = { '1': 1, // 1级权限:每天1个 '2': 10, // 2级权限:每天10个 '3': 100, // 3级权限:每天100个 '4': -1 // 4级权限:无限制 };

    const totalLimit = dailyLimits[userRole] || 1; const roleNames = { '1': '1级', '2': '2级', '3': '3级', '4': '4级' };

    res.status(200).json({ code: 200, message: '获取每日限制状态成功', data: { userRole: userRole, roleName: roleNames[userRole], totalLimit: totalLimit === -1 ? '无限制' : totalLimit, usedCount: currentCount, remaining: limitStatus.remaining === -1 ? '无限制' : limitStatus.remaining, canApply: limitStatus.allowed, resetTime: '每日00:00重置' } }); } catch (error) { console.error('获取每日限制状态失败:', error); res.status(500).json({ code: 500, message: '获取每日限制状态失败', data: null }); } });

module.exports = router; // model/video/videoRouters.js

const express = require("express"); const router = express.Router(); const videoUtils = require("./videoUtils"); const bilibiliUtils = require("../bilibili/bilibiliUtils"); const authorize = require("../auth/authUtils"); // 导入授权中间件

/**

  • @api {get} /api/video/list
  • @description 获取所有已处理的视频列表
  • @access Public */ router.get("/list", async (req, res) => { try { const videos = await videoUtils.listAllVideos(); res.status(200).json({ code: 200, message: "成功获取视频列表", data: videos, }); } catch (error) { res.status(500).json({ code: 500, message: error.message || "获取视频列表失败", data: null, }); } });

/**

  • @api {get} /api/video/user-list
  • @description 获取当前用户处理的视频列表
  • @access Protected - 需要用户登录 */ router.get("/user-list", authorize(["1", "2", "3"]), async (req, res) => { try { const userId = req.user.uid || req.user.id; const videos = await videoUtils.getUserVideos(userId); res.status(200).json({ code: 200, message: "成功获取用户视频列表", data: videos, }); } catch (error) { res.status(500).json({ code: 500, message: error.message || "获取用户视频列表失败", data: null, }); } });

/**

  • @api {post} /api/video/parse

  • @description 解析B站视频信息(不下载,仅获取视频详情)

  • @access Protected - 需要用户登录和B站账号

  • @body { "url": "视频的URL或BVID", "quality": "清晰度(可选)" } */ router.post("/parse", authorize(["1", "2", "3"]), async (req, res) => { try { const userId = req.user.uid || req.user.id; const { url, quality = 80 } = req.body;

    if (!url || !url.trim()) { return res.status(400).json({ code: 400, message: "请提供有效的视频 URL", data: null, }); }

    // 检查用户是否有活跃的B站账号 const bilibiliAccount = await bilibiliUtils.getActiveBilibiliAccount(userId); if (!bilibiliAccount) { return res.status(400).json({ code: 400, message: "请先登录B站账号", data: null }); }

    console.log(▶️ 开始解析视频: ${url}); const result = await videoUtils.parseVideoInfo(url, bilibiliAccount.cookie_string, quality); console.log(✅ 视频解析完成: ${result.title});

    res.status(200).json({ code: 200, message: "视频解析成功", data: result, }); } catch (error) { console.error(❌ 解析视频失败:, error); res.status(500).json({ code: 500, message: error.message || "解析视频失败", data: null, }); } });

/**

  • @api {post} /api/video/process

  • @description 处理B站视频(解析、下载、合并、入库)

  • @access Protected - 需要用户登录和B站账号

  • @body { "url": "视频的URL或BVID", "quality": "清晰度(可选)", "downloadMode": "下载模式(可选)" } */ router.post("/process", authorize(["1", "2", "3"]), async (req, res) => { try { const userId = req.user.uid || req.user.id; const { url, quality = 80, downloadMode = "auto" } = req.body;

    if (!url || !url.trim()) { return res.status(400).json({ code: 400, message: "请提供有效的视频 URL", data: null, }); }

    // 检查用户是否有活跃的B站账号 const bilibiliAccount = await bilibiliUtils.getActiveBilibiliAccount(userId); if (!bilibiliAccount) { return res.status(400).json({ code: 400, message: "请先登录B站账号", data: null }); }

    console.log(▶️ 开始处理视频请求: ${url}); const result = await videoUtils.processVideoRequest({ url, userId, cookieString: bilibiliAccount.cookie_string, quality, downloadMode, bilibiliAccountId: bilibiliAccount.id }); console.log(✅ 视频处理完成: ${result.title});

    res.status(201).json({ code: 201, message: "视频处理成功并已入库", data: result, }); } catch (error) { console.error(❌ 处理视频失败:, error); res.status(500).json({ code: 500, message: error.message || "处理视频时发生未知错误", data: null, }); } });

/**

  • @api {post} /api/video/batch-process

  • @description 批量处理B站视频

  • @access Protected - 需要用户登录和B站账号

  • @body { "urls": ["视频URL数组"], "quality": "清晰度(可选)", "downloadMode": "下载模式(可选)" } */ router.post("/batch-process", authorize(["1", "2", "3"]), async (req, res) => { try { const userId = req.user.uid || req.user.id; const { urls, quality = 80, downloadMode = "auto" } = req.body;

    if (!urls || !Array.isArray(urls) || urls.length === 0) { return res.status(400).json({ code: 400, message: "请提供有效的视频 URL 数组", data: null, }); }

    if (urls.length > 10) { return res.status(400).json({ code: 400, message: "批量处理最多支持10个视频", data: null, }); }

    // 检查用户是否有活跃的B站账号 const bilibiliAccount = await bilibiliUtils.getActiveBilibiliAccount(userId); if (!bilibiliAccount) { return res.status(400).json({ code: 400, message: "请先登录B站账号", data: null }); }

    console.log(▶️ 开始批量处理 ${urls.length} 个视频); const results = await videoUtils.batchProcessVideos({ urls, userId, cookieString: bilibiliAccount.cookie_string, quality, downloadMode, bilibiliAccountId: bilibiliAccount.id }); console.log(✅ 批量处理完成,成功: ${results.success.length}, 失败: ${results.failed.length});

    res.status(200).json({ code: 200, message: 批量处理完成,成功: ${results.success.length}, 失败: ${results.failed.length}, data: results, }); } catch (error) { console.error(❌ 批量处理视频失败:, error); res.status(500).json({ code: 500, message: error.message || "批量处理视频失败", data: null, }); } });

/**

  • @api {delete} /api/video/:id

  • @description 删除视频记录和文件

  • @access Protected - 需要用户登录 */ router.delete("/:id", authorize(["1", "2", "3"]), async (req, res) => { try { const userId = req.user.uid || req.user.id; const { id } = req.params; const { deleteFile = false } = req.query;

    await videoUtils.deleteVideo(id, userId, deleteFile === 'true');

    res.status(200).json({ code: 200, message: "视频删除成功", data: null, }); } catch (error) { console.error(❌ 删除视频失败:, error); res.status(500).json({ code: 500, message: error.message || "删除视频失败", data: null, }); } });

/**

  • @api {post} /api/video/generate-download-link

  • @description 生成安全下载链接

  • @access Protected - 需要用户登录 */ router.post("/generate-download-link", authorize(["1", "2", "3"]), async (req, res) => { try { const { fileName } = req.body; const userId = req.user.uid || req.user.id;

    if (!fileName) { return res.status(400).json({ code: 400, message: "文件名不能为空", data: null, }); }

    // 检查用户是否有权限下载该文件 const hasPermission = await videoUtils.checkDownloadPermission(fileName, userId); if (!hasPermission) { return res.status(403).json({ code: 403, message: "无权限下载该文件", data: null, }); }

    // 生成安全下载链接 const downloadInfo = videoUtils.generateSecureDownloadLink(fileName, userId);

    res.status(200).json({ code: 200, message: "下载链接生成成功", data: downloadInfo, }); } catch (error) { console.error("生成下载链接失败:", error); res.status(500).json({ code: 500, message: error.message || "生成下载链接失败", data: null, }); } });

/**

  • @api {get} /api/video/secure-download

  • @description 安全文件下载(支持断点续传)

  • @access Public - 通过token验证 */ router.get("/secure-download", async (req, res) => { try { const { token, file } = req.query;

    if (!token || !file) { return res.status(400).json({ code: 400, message: "缺少必要参数", data: null, }); }

    // 验证token const payload = videoUtils.verifyDownloadToken(token); if (!payload) { return res.status(401).json({ code: 401, message: "下载链接已过期或无效", data: null, }); }

    // 验证文件名是否匹配 if (payload.fileName !== file) { return res.status(403).json({ code: 403, message: "文件访问权限验证失败", data: null, }); }

    // 再次检查用户权限 const hasPermission = await videoUtils.checkDownloadPermission(file, payload.userId); if (!hasPermission) { return res.status(403).json({ code: 403, message: "无权限下载该文件", data: null, }); }

    // 处理安全下载 await videoUtils.handleSecureDownload(file, req, res);

} catch (error) { console.error("安全下载失败:", error); if (!res.headersSent) { res.status(500).json({ code: 500, message: error.message || "下载失败", data: null, }); } } });

/**

  • @api {get} /api/video/download/:bvid

  • @description 直接下载视频(兼容旧版本)

  • @access Protected - 需要用户登录 */ router.get("/download/:bvid", authorize(["1", "2", "3"]), async (req, res) => { try { const { bvid } = req.params; const userId = req.user.uid || req.user.id;

    // 构造文件名 const fileName = ${bvid}.mp4;

    // 检查用户是否有权限下载该文件 const hasPermission = await videoUtils.checkDownloadPermission(fileName, userId); if (!hasPermission) { return res.status(403).json({ code: 403, message: "无权限下载该文件,请先添加下载权限", data: null, }); }

    // 处理安全下载 await videoUtils.handleSecureDownload(fileName, req, res);

} catch (error) { console.error("直接下载失败:", error); if (!res.headersSent) { res.status(500).json({ code: 500, message: error.message || "下载失败", data: null, }); } } });

/**

  • @api {get} /api/video/available

  • @description 获取所有可下载的视频列表(公开接口)

  • @access Public */ router.get("/available", async (req, res) => { try { const { limit = 20, offset = 0 } = req.query; // 确保参数是有效的数字,避免传递NaN const parsedLimit = parseInt(limit); const parsedOffset = parseInt(offset); const validLimit = Math.max(1, Math.min(100, isNaN(parsedLimit) ? 20 : parsedLimit)); const validOffset = Math.max(0, isNaN(parsedOffset) ? 0 : parsedOffset);

    const result = await videoUtils.getAvailableVideos( validLimit, validOffset );

    res.status(200).json({ code: 200, message: "成功获取可下载视频列表", data: result, }); } catch (error) { console.error("获取可下载视频列表失败:", error); res.status(500).json({ code: 500, message: error.message || "获取视频列表失败", data: null, }); } });

/**

  • @api {post} /api/video/add-download-permission

  • @description 添加视频下载权限

  • @access Protected - 需要用户登录

  • @body { "bvid": "视频BVID" } */ router.post("/add-download-permission", authorize(["1", "2", "3", "4"]), async (req, res) => { try { const userId = req.user.uid || req.user.id; const { bvid } = req.body;

    if (!bvid || !bvid.trim()) { return res.status(400).json({ code: 400, message: "请提供有效的视频BVID", data: null, }); }

    const result = await videoUtils.addVideoDownloader(userId, bvid.trim());

    res.status(200).json({ code: 200, message: result.message, data: result, }); } catch (error) { console.error("添加下载权限失败:", error); res.status(500).json({ code: 500, message: error.message || "添加下载权限失败", data: null, }); } });

/**

  • @api {get} /api/video/my-permissions/:bvid

  • @description 查看用户对特定视频的权限

  • @access Protected - 需要用户登录 */ router.get("/my-permissions/:bvid", authorize(["1", "2", "3"]), async (req, res) => { try { const userId = req.user.uid || req.user.id; const { bvid } = req.params;

    // 检查用户对该视频的权限 const fileName = ${bvid}.mp4; const hasPermission = await videoUtils.checkDownloadPermission(fileName, userId);

    if (hasPermission) { // 获取具体的关系类型 const db = require("../../config/db").promise(); const [relations] = await db.execute( SELECT uv.relation_type, uv.created_at, v.title FROM user_videos uv INNER JOIN videos v ON uv.video_id = v.id WHERE uv.user_id = ? AND v.bvid = ?, [userId, bvid] );

    if (relations.length > 0) { const relation = relations[0]; res.status(200).json({ code: 200, message: "有权限访问该视频", data: { hasPermission: true, relationType: relation.relation_type, relationDesc: videoUtils.getRelationTypeDesc ? videoUtils.getRelationTypeDesc(relation.relation_type) : relation.relation_type, addedAt: relation.created_at, videoTitle: relation.title }, }); } else { res.status(200).json({ code: 200, message: "无权限访问该视频", data: { hasPermission: false }, }); } } else { res.status(200).json({ code: 200, message: "无权限访问该视频", data: { hasPermission: false }, }); } } catch (error) { console.error("查询权限失败:", error); res.status(500).json({ code: 500, message: error.message || "查询权限失败", data: null, }); } });

module.exports = router; // model/video/videoUtils.js

const axios = require("axios"); const fs = require("fs"); const path = require("path"); const { spawn } = require("child_process"); const { v4: uuidv4 } = require("uuid"); const jwt = require("jsonwebtoken"); const crypto = require("crypto"); const db = require("../../config/db").promise(); const bilibiliUtils = require("../bilibili/bilibiliUtils"); const { Worker } = require("worker_threads"); const EventEmitter = require("events");

// 配置路径 const DOWNLOAD_DIR = path.join(__dirname, "../../downloads"); // 临时下载目录 const VIDEO_DIR = path.join(__dirname, "../../videos"); // 最终视频存储目录 const FFMPEG_PATH = "ffmpeg"; // FFmpeg 可执行文件路径,确保已安装并在 PATH 中 const FFPROBE_PATH = "ffprobe"; // FFprobe 可执行文件路径,用于检测视频信息

// 确保目录存在 if (!fs.existsSync(DOWNLOAD_DIR)) { fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); console.log(📁 创建临时下载目录: ${DOWNLOAD_DIR}); }

if (!fs.existsSync(VIDEO_DIR)) { fs.mkdirSync(VIDEO_DIR, { recursive: true }); console.log(📁 创建视频存储目录: ${VIDEO_DIR}); }

// 视频合并队列管理系统 class VideoMergeQueue extends EventEmitter { constructor(maxConcurrent = 2) { super(); this.maxConcurrent = maxConcurrent; // 最大并发数 this.currentTasks = 0; // 当前运行任务数 this.queue = []; // 任务队列 this.taskStatus = new Map(); // 任务状态存储 }

// 添加合并任务到队列 addTask(taskId, videoPath, audioPath, outputPath, progressCallback) { return new Promise((resolve, reject) => { const task = { id: taskId, videoPath, audioPath, outputPath, progressCallback, resolve, reject, status: 'queued', createdAt: Date.now() };

  this.queue.push(task);
  this.taskStatus.set(taskId, {
    status: 'queued',
    progress: 0,
    createdAt: Date.now()
  });

  console.log(`📋 任务 ${taskId} 已加入队列,当前队列长度: ${this.queue.length}`);
  this.processQueue();
});

}

// 处理队列 async processQueue() { if (this.currentTasks >= this.maxConcurrent || this.queue.length === 0) { return; }

const task = this.queue.shift();
this.currentTasks++;

task.status = 'processing';
this.taskStatus.set(task.id, {
  status: 'processing',
  progress: 0,
  startedAt: Date.now()
});

console.log(`🔧 开始处理任务 ${task.id},当前并发数: ${this.currentTasks}`);

try {
  await this.executeMergeTask(task);
  task.resolve();
  this.taskStatus.set(task.id, {
    status: 'completed',
    progress: 100,
    completedAt: Date.now()
  });
  console.log(`✅ 任务 ${task.id} 完成`);
} catch (error) {
  task.reject(error);
  this.taskStatus.set(task.id, {
    status: 'failed',
    error: error.message,
    failedAt: Date.now()
  });
  console.error(`❌ 任务 ${task.id} 失败:`, error.message);
} finally {
  this.currentTasks--;
  // 清理过期的任务状态(保留1小时)
  this.cleanupExpiredTasks();
  // 继续处理队列中的下一个任务
  this.processQueue();
}

}

// 执行合并任务 async executeMergeTask(task) { return new Promise(async (resolve, reject) => { const { videoPath, audioPath, outputPath, progressCallback, id } = task;

  // 设置FFmpeg事件处理器的通用函数
  const setupFFmpegHandlers = (ffmpegProcess) => {
    let duration = null;
    
    ffmpegProcess.stderr.on("data", (data) => {
      const output = data.toString();
      
      // 提取总时长
      if (!duration) {
        const durationMatch = output.match(/Duration: (\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
        if (durationMatch) {
          const hours = parseInt(durationMatch[1]);
          const minutes = parseInt(durationMatch[2]);
          const seconds = parseInt(durationMatch[3]);
          duration = hours * 3600 + minutes * 60 + seconds;
        }
      }
      
      // 提取当前进度
      if (duration) {
        const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
        if (timeMatch) {
          const hours = parseInt(timeMatch[1]);
          const minutes = parseInt(timeMatch[2]);
          const seconds = parseInt(timeMatch[3]);
          const currentTime = hours * 3600 + minutes * 60 + seconds;
          const progress = (currentTime / duration * 100).toFixed(2);
          
          // 更新任务状态
          this.taskStatus.set(id, {
            status: 'processing',
            progress: parseFloat(progress),
            currentTime,
            duration,
            updatedAt: Date.now()
          });
          
          // 调用进度回调
          if (progressCallback) {
            progressCallback(progress, currentTime, duration);
          }
        }
      }
    });

    ffmpegProcess.on("close", (code) => {
      if (code === 0) {
        resolve();
      } else {
        reject(new Error(`FFmpeg 合并失败,退出代码: ${code}`));
      }
    });

    ffmpegProcess.on("error", (error) => {
      reject(error);
    });
  };
  
  try {
    // 检测视频编码格式
    console.log(`🔍 正在检测视频编码格式: ${videoPath}`);
    const codecInfo = await detectVideoCodec(videoPath);
    
    // 根据编码格式和鸿蒙兼容性选择处理方式
    let videoCodecArgs;
    if (codecInfo.isHarmonyCompatible) {
      // H.264格式,鸿蒙兼容,直接复制
      console.log(`✅ 检测到H.264编码,使用快速复制模式`);
      videoCodecArgs = ["-c:v", "copy"];
    } else {
      // 非H.264格式(如H.265),需要转码为H.264以兼容鸿蒙
      console.log(`⚠️ 检测到${codecInfo.codec_name}编码,转码为H.264以兼容鸿蒙系统`);
      videoCodecArgs = [
        "-c:v", "libx264",
        "-preset", "fast",
        "-crf", "23",
        "-profile:v", "high",
        "-level", "4.1"
      ];
    }
    
    const ffmpegArgs = [
      "-i", videoPath,
      "-i", audioPath,
      ...videoCodecArgs,
      "-c:a", "aac",
      "-b:a", "128k",
      "-movflags", "+faststart", // 优化网络播放
      "-strict", "experimental",
      "-y", // 覆盖输出文件
      outputPath,
    ];
    
    console.log(`🎬 开始合并视频,FFmpeg参数:`, ffmpegArgs.join(' '));
    const ffmpeg = spawn(FFMPEG_PATH, ffmpegArgs);
    
    // 存储编码信息到任务状态中
    const taskStatus = this.taskStatus.get(id);
    if (taskStatus) {
      taskStatus.codecInfo = codecInfo;
      taskStatus.processingMode = codecInfo.isHarmonyCompatible ? 'copy' : 'transcode';
    }
    
    // 为正常流程的ffmpeg设置进度处理器
    setupFFmpegHandlers(ffmpeg);
  } catch (codecError) {
     console.error(`❌ 视频编码检测失败,使用默认H.264转码模式:`, codecError.message);
     // 检测失败时,使用安全的H.264转码模式
     const ffmpeg = spawn(FFMPEG_PATH, [
       "-i", videoPath,
       "-i", audioPath,
       "-c:v", "libx264",
       "-preset", "fast",
       "-crf", "23",
       "-c:a", "aac",
       "-b:a", "128k",
       "-movflags", "+faststart",
       "-y",
       outputPath,
     ]);
     
     // 为错误处理分支也添加进度处理
     setupFFmpegHandlers(ffmpeg);
   }
   
   // 为正常流程的ffmpeg也设置处理器
   if (typeof ffmpeg !== 'undefined') {
     setupFFmpegHandlers(ffmpeg);
   }
 });

}

// 获取任务状态 getTaskStatus(taskId) { return this.taskStatus.get(taskId) || { status: 'not_found' }; }

// 获取队列状态 getQueueStatus() { return { queueLength: this.queue.length, currentTasks: this.currentTasks, maxConcurrent: this.maxConcurrent, totalTasks: this.taskStatus.size }; }

// 清理过期任务状态 cleanupExpiredTasks() { const oneHourAgo = Date.now() - (60 * 60 * 1000); for (const [taskId, status] of this.taskStatus.entries()) { const lastUpdate = status.completedAt || status.failedAt || status.updatedAt || status.createdAt; if (lastUpdate < oneHourAgo && ['completed', 'failed'].includes(status.status)) { this.taskStatus.delete(taskId); } } } }

// 创建全局队列实例 const videoMergeQueue = new VideoMergeQueue(2); // 最大并发数为2

/**

  • 检测视频编码格式

  • @param {string} videoPath - 视频文件路径

  • @returns {Promise} 视频编码信息 */ function detectVideoCodec(videoPath) { return new Promise((resolve, reject) => { const ffprobe = spawn(FFPROBE_PATH, [ "-v", "quiet", "-print_format", "json", "-show_streams", "-select_streams", "v:0", // 只选择第一个视频流 videoPath ]);

    let output = ""; let errorOutput = "";

    ffprobe.stdout.on("data", (data) => { output += data.toString(); });

    ffprobe.stderr.on("data", (data) => { errorOutput += data.toString(); });

    ffprobe.on("close", (code) => { if (code !== 0) { console.error(❌ FFprobe 检测失败:, errorOutput); reject(new Error(FFprobe 检测失败: ${errorOutput})); return; }

    try { const result = JSON.parse(output); const videoStream = result.streams[0];

     if (!videoStream) {
       reject(new Error("未找到视频流"));
       return;
     }
    
     const codecInfo = {
       codec_name: videoStream.codec_name,
       codec_long_name: videoStream.codec_long_name,
       profile: videoStream.profile,
       level: videoStream.level,
       width: videoStream.width,
       height: videoStream.height,
       bit_rate: videoStream.bit_rate,
       duration: videoStream.duration,
       isH264: videoStream.codec_name === "h264",
       isH265: videoStream.codec_name === "hevc" || videoStream.codec_name === "h265",
       isHarmonyCompatible: videoStream.codec_name === "h264" // 鸿蒙系统兼容性
     };
    
     console.log(`🔍 视频编码检测结果: ${codecInfo.codec_name} (${codecInfo.codec_long_name})`);
     resolve(codecInfo);
    

    } catch (parseError) { console.error(❌ 解析FFprobe输出失败:, parseError.message); reject(new Error(解析FFprobe输出失败: ${parseError.message})); } });

    ffprobe.on("error", (error) => { console.error(❌ FFprobe 启动失败:, error.message); reject(new Error(FFprobe 启动失败: ${error.message})); }); }); }

// 视频质量映射 const QUALITY_MAP = { 120: "4K 超清", 116: "1080P60 高清", 112: "1080P+ 高清", 80: "1080P 高清", 74: "720P60 高清", 64: "720P 高清", 32: "480P 清晰", 16: "360P 流畅" };

/**

  • 提取BVID从URL
  • @param {string} url - 视频URL或BVID
  • @returns {string} BVID */ function extractBVID(url) { if (url.startsWith('BV')) { return url; } const bvidMatch = url.match(/BV[a-zA-Z0-9]+/); if (bvidMatch) { return bvidMatch[0]; } throw new Error('无法从URL中提取BVID'); }

/**

  • 解析B站视频信息(使用B站账号Cookie)

  • @param {string} url - 视频URL或BVID

  • @param {string} cookieString - B站账号Cookie

  • @param {number} quality - 视频质量

  • @returns {Promise} 视频信息 */ async function parseVideoInfo(url, cookieString, quality = 80) { try { const bvid = extractBVID(url); console.log(🔍 正在解析视频信息: ${bvid});

    // 获取视频信息和下载链接 const videoInfo = await bilibiliUtils.getBilibiliVideoInfo(bvid, cookieString);

    const result = { bvid: bvid, aid: videoInfo.aid || null, title: videoInfo.title, description: videoInfo.description, duration: videoInfo.duration, view: videoInfo.stat.view, danmaku: videoInfo.stat.danmaku, like: videoInfo.stat.like, coin: videoInfo.stat.coin, share: videoInfo.stat.share, reply: videoInfo.stat.reply, favorite: videoInfo.stat.favorite, owner: { mid: videoInfo.owner.mid, name: videoInfo.owner.name, face: videoInfo.owner.face || null }, pubdate: videoInfo.pubdate || null, pic: videoInfo.pic, pages: videoInfo.pages || [], cid: videoInfo.cid || null, tname: videoInfo.tname || null, current_viewers: videoInfo.stat.now_rank || 0, quality: quality, qualityDesc: QUALITY_MAP[quality] || '未知画质', downloadUrls: videoInfo.downloadUrls, videoUrl: videoInfo.downloadUrls.video, audioUrl: videoInfo.downloadUrls.audio, fileSize: null // 文件大小需要在下载时获取 };

    console.log(✅ 视频信息解析完成: ${result.title}); return result; } catch (error) { console.error(❌ 解析视频信息失败:, error.message); throw new Error(解析视频信息失败: ${error.message}); } }

/**

  • 下载文件(支持进度回调)

  • @param {string} url - 下载链接

  • @param {string} filePath - 保存路径

  • @param {string} cookieString - B站Cookie

  • @param {Function} progressCallback - 进度回调函数

  • @returns {Promise} */ async function downloadFile(url, filePath, cookieString, progressCallback) { try { console.log(⬇️ 开始下载文件: ${path.basename(filePath)});

    const response = await axios({ method: "GET", url: url, responseType: "stream", headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://www.bilibili.com/", "Cookie": cookieString, "Accept": "/", "Accept-Encoding": "gzip, deflate, br", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" }, timeout: 30000 });

    const totalLength = parseInt(response.headers['content-length'], 10); let downloadedLength = 0;

    const writer = fs.createWriteStream(filePath);

    response.data.on('data', (chunk) => { downloadedLength += chunk.length; if (progressCallback && totalLength) { const progress = (downloadedLength / totalLength * 100).toFixed(2); progressCallback(progress, downloadedLength, totalLength); } });

    response.data.pipe(writer);

    return new Promise((resolve, reject) => { writer.on("finish", () => { console.log(✅ 文件下载完成: ${path.basename(filePath)}); resolve(); }); writer.on("error", (error) => { console.error(❌ 文件下载失败: ${path.basename(filePath)}, error); reject(error); }); }); } catch (error) { console.error(❌ 下载文件失败: ${path.basename(filePath)}, error.message); throw error; } }

/**

  • 使用 FFmpeg 合并视频和音频(支持进度回调)- 队列版本
  • @param {string} videoPath - 视频文件路径
  • @param {string} audioPath - 音频文件路径
  • @param {string} outputPath - 输出文件路径
  • @param {Function} progressCallback - 进度回调函数
  • @returns {Promise} */ function mergeVideoAndAudio(videoPath, audioPath, outputPath, progressCallback) { // 生成唯一任务ID const taskId = merge_${Date.now()}_${Math.random().toString(36).substr(2, 9)};

console.log(🔧 将视频合并任务加入队列: ${path.basename(outputPath)} (任务ID: ${taskId}));

// 将任务添加到队列中 return videoMergeQueue.addTask(taskId, videoPath, audioPath, outputPath, progressCallback); }

/**

  • 获取合并任务状态
  • @param {string} taskId - 任务ID
  • @returns {Object} 任务状态 */ function getMergeTaskStatus(taskId) { return videoMergeQueue.getTaskStatus(taskId); }

/**

  • 获取合并队列状态
  • @returns {Object} 队列状态 */ function getMergeQueueStatus() { return videoMergeQueue.getQueueStatus(); }

/**

  • 直接执行合并(不使用队列,用于紧急情况)

  • @param {string} videoPath - 视频文件路径

  • @param {string} audioPath - 音频文件路径

  • @param {string} outputPath - 输出文件路径

  • @param {Function} progressCallback - 进度回调函数

  • @returns {Promise} */ function mergeVideoAndAudioDirect(videoPath, audioPath, outputPath, progressCallback) { return new Promise((resolve, reject) => { console.log(🔧 直接合并视频和音频: ${path.basename(outputPath)});

    const ffmpeg = spawn(FFMPEG_PATH, [ "-i", videoPath, "-i", audioPath, "-c:v", "copy", "-c:a", "aac", "-strict", "experimental", "-y", // 覆盖输出文件 outputPath, ]);

    let duration = null;

    ffmpeg.stderr.on("data", (data) => { const output = data.toString();

    // 提取总时长 if (!duration) { const durationMatch = output.match(/Duration: (\d{2})😦\d{2})😦\d{2}).(\d{2})/); if (durationMatch) { const hours = parseInt(durationMatch[1]); const minutes = parseInt(durationMatch[2]); const seconds = parseInt(durationMatch[3]); duration = hours * 3600 + minutes * 60 + seconds; } }

    // 提取当前进度 if (duration && progressCallback) { const timeMatch = output.match(/time=(\d{2})😦\d{2})😦\d{2}).(\d{2})/); if (timeMatch) { const hours = parseInt(timeMatch[1]); const minutes = parseInt(timeMatch[2]); const seconds = parseInt(timeMatch[3]); const currentTime = hours * 3600 + minutes * 60 + seconds; const progress = (currentTime / duration * 100).toFixed(2); progressCallback(progress, currentTime, duration); } } });

    ffmpeg.on("close", (code) => { if (code === 0) { console.log(✅ 视频合并完成: ${path.basename(outputPath)}); resolve(); } else { console.error(❌ FFmpeg 进程退出,代码: ${code}); reject(new Error(FFmpeg 合并失败,退出代码: ${code})); } });

    ffmpeg.on("error", (error) => { console.error(❌ FFmpeg 启动失败:, error); reject(error); }); }); }

/**

  • 将视频信息保存到数据库

  • @param {Object} videoInfo - 视频信息

  • @param {string} filePath - 文件路径

  • @param {string} playUrl - 播放地址

  • @param {number} userId - 用户ID

  • @param {number} bilibiliAccountId - B站账号ID

  • @returns {Promise} 数据库记录 */ async function saveOrUpdateVideoInDb(videoInfo, filePath, playUrl, userId, bilibiliAccountId) { try { console.log(💾 保存视频信息到数据库: ${videoInfo.title});

    // 检查视频是否已存在(根据bvid) const [existingVideos] = await db.execute( "SELECT * FROM videos WHERE bvid = ?", [videoInfo.bvid] );

    let videoId; let isNewVideo = false;

    if (existingVideos.length > 0) { // 更新现有记录 videoId = existingVideos[0].id; await db.execute( UPDATE videos SET title = ?, pic = ?, view = ?, danmaku = ?, \like` = ?, coin = ?, favorite = ?, share = ?, reply = ?, name = ?, face = ?, pubdate = ?, quality = ?, `desc` = ?, duration = ?, aid = ?, download_link = ?, cid = ?, tname = ?, current_viewers = ? WHERE bvid = ?`, [ videoInfo.title, videoInfo.pic || "", videoInfo.view || 0, videoInfo.danmaku || 0, videoInfo.like || 0, videoInfo.coin || 0, videoInfo.favorite || 0, videoInfo.share || 0, videoInfo.reply || 0, videoInfo.owner?.name || "未知", videoInfo.owner?.face || "", videoInfo.pubdate || "", videoInfo.quality || 80, videoInfo.description || "", videoInfo.duration || 0, videoInfo.aid || "", playUrl, videoInfo.cid || "", videoInfo.tname || "", videoInfo.current_viewers || 0, videoInfo.bvid ] );

    console.log(✅ 视频信息已更新: ${videoInfo.title}); } else { // 插入新记录 const [result] = await db.execute( INSERT INTO videos ( bvid, aid, title, pic, view, danmaku, \like`, coin, favorite, share, reply, name, face, pubdate, quality, `desc`, duration, download_link, cid, tname, current_viewers ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
    videoInfo.bvid, videoInfo.aid || "", videoInfo.title, videoInfo.pic || "", videoInfo.view || 0, videoInfo.danmaku || 0, videoInfo.like || 0, videoInfo.coin || 0, videoInfo.favorite || 0, videoInfo.share || 0, videoInfo.reply || 0, videoInfo.owner?.name || "未知", videoInfo.owner?.face || "", videoInfo.pubdate || "", videoInfo.quality || 80, videoInfo.description || "", videoInfo.duration || 0, playUrl, videoInfo.cid || "", videoInfo.tname || "", videoInfo.current_viewers || 0 ] );

    videoId = result.insertId; isNewVideo = true; console.log(✅ 视频信息已保存: ${videoInfo.title}); }

    // 检查用户视频关联关系是否已存在 const [existingRelation] = await db.execute( "SELECT * FROM user_videos WHERE user_id = ? AND video_id = ? AND relation_type = 'processor'", [userId, videoId] );

    if (existingRelation.length === 0) { // 创建用户视频关联关系(处理者) await db.execute( "INSERT INTO user_videos (user_id, video_id, relation_type) VALUES (?, ?, 'processor')", [userId, videoId] ); console.log(🔗 已创建用户视频关联关系: 用户${userId} -> 视频${videoId}); }

    // 如果视频有UP主信息,尝试创建UP主关联关系 if (videoInfo.owner?.mid) { // 这里可以扩展:如果系统中有对应的UP主用户,可以创建owner关联 // 暂时只记录processor关联 }

    return { id: videoId, updated: !isNewVideo, title: videoInfo.title, bvid: videoInfo.bvid, filePath: filePath, playUrl: playUrl }; } catch (error) { console.error('❌ 保存视频信息到数据库失败:', error); throw error; } }

/**

  • 获取所有视频列表
  • @returns {Promise} 视频列表 */ async function listAllVideos() { try { const [videos] = await db.execute( SELECT * FROM videos ORDER BY id DESC ); return videos; } catch (error) { console.error(❌ 获取视频列表失败:, error); throw error; } }

/**

  • 获取用户处理的视频列表

  • @param {number} userId - 用户ID

  • @returns {Promise} 视频列表 */ async function getUserVideos(userId) { try { console.log(🔍 获取用户 ${userId} 的视频列表);

    // 通过user_videos关联表查询用户相关的视频 const [videos] = await db.execute( SELECT v.*, uv.relation_type, uv.created_at as relation_created_at FROM videos v INNER JOIN user_videos uv ON v.id = uv.video_id WHERE uv.user_id = ? ORDER BY uv.created_at DESC, v.id DESC, [userId] );

    console.log(✅ 找到 ${videos.length} 个相关视频);

    // 为每个视频添加关系类型的中文描述 const videosWithRelationDesc = videos.map(video => ({ ...video, relation_desc: getRelationTypeDesc(video.relation_type) }));

    return videosWithRelationDesc; } catch (error) { console.error(❌ 获取用户视频列表失败:, error); throw error; } }

/**

  • 获取关系类型的中文描述
  • @param {string} relationType - 关系类型
  • @returns {string} 中文描述 */ function getRelationTypeDesc(relationType) { const relationMap = { 'owner': 'UP主', 'processor': '处理者', 'downloader': '下载者' }; return relationMap[relationType] || '未知关系'; }

/**

  • 删除视频记录和文件

  • @param {number} videoId - 视频ID

  • @param {number} userId - 用户ID

  • @param {boolean} deleteFile - 是否删除文件

  • @returns {Promise} */ async function deleteVideo(videoId, userId, deleteFile = false) { try { console.log(🗑️ 用户 ${userId} 尝试删除视频 ${videoId});

    // 检查用户是否有权限删除该视频(必须是处理者或下载者) const [userVideoRelations] = await db.execute( SELECT uv.*, v.title, v.bvid, v.download_link FROM user_videos uv INNER JOIN videos v ON uv.video_id = v.id WHERE uv.user_id = ? AND uv.video_id = ? AND uv.relation_type IN ('processor', 'downloader'), [userId, videoId] );

    if (userVideoRelations.length === 0) { throw new Error('无权限删除该视频:您不是该视频的处理者或下载者'); }

    const videoInfo = userVideoRelations[0]; console.log(✅ 权限验证通过,用户是视频的${getRelationTypeDesc(videoInfo.relation_type)});

    // 获取完整视频信息 const [videos] = await db.execute( "SELECT * FROM videos WHERE id = ?", [videoId] );

    if (videos.length === 0) { throw new Error('视频不存在'); }

    const video = videos[0];

    // 删除用户视频关联记录 await db.execute( "DELETE FROM user_videos WHERE video_id = ?", [videoId] ); console.log(🔗 已删除用户视频关联记录);

    // 删除视频记录 await db.execute("DELETE FROM videos WHERE id = ?", [videoId]); console.log(📝 已删除视频数据库记录);

    // 删除文件 if (deleteFile) { // 尝试从download_link推断文件路径 let filePath = null; if (video.download_link) { // 从下载链接中提取文件名 const fileName = video.download_link.split('/').pop(); filePath = path.join(VIDEO_DIR, fileName); }

    // 如果有file_path字段,优先使用 if (video.file_path) { filePath = video.file_path; }

    if (filePath && fs.existsSync(filePath)) { fs.unlinkSync(filePath); console.log(🗑️ 已删除视频文件: ${filePath}); } else if (filePath) { console.warn(⚠️ 视频文件不存在: ${filePath}); } else { console.warn(⚠️ 无法确定视频文件路径); } }

    console.log(✅ 成功删除视频: ${video.title} (${video.bvid})); } catch (error) { console.error(❌ 删除视频失败:, error); throw error; } }

/**

  • 处理视频请求的主函数
  • @param {Object} options - 处理选项
  • @returns {Promise} 处理结果 */ async function processVideoRequest(options) { const { url, userId, cookieString, quality = 80, downloadMode = "auto", bilibiliAccountId } = options;

try { // 0. 提取BVID进行预检查 const bvid = extractBVID(url); if (!bvid) { throw new Error('无法从URL中提取BVID'); }

// 1. 检查数据库和文件是否已存在(优化:避免重复解析)
const finalFileName = `${bvid}.mp4`;
const finalVideoPath = path.join(VIDEO_DIR, finalFileName);

// 检查数据库中是否已有记录
const [existingRecords] = await db.execute(
  'SELECT * FROM videos WHERE bvid = ?',
  [bvid]
);

// 检查文件是否存在
const fileExists = fs.existsSync(finalVideoPath);

if (existingRecords.length > 0 && fileExists) {
  console.log(`✅ 发现已存在的视频记录和文件: ${bvid}`);
  
  // 只解析基本信息用于更新数据库
  const videoInfo = await parseVideoInfo(url, cookieString, quality);
  
  // 生成播放地址
  const serverPort = process.env.PORT || 3000;
  const serverHost = process.env.SERVER_HOST || 'localhost';
  const playUrl = `http://${serverHost}:${serverPort}/api/video/download/${finalFileName}`;
  
  // 更新数据库记录(保持文件路径不变)
  const existingRecord = existingRecords[0];
  await db.execute(
    `UPDATE videos SET 
     title = ?, pic = ?, view = ?, danmaku = ?, \`like\` = ?, 
     coin = ?, favorite = ?, share = ?, reply = ?, 
     name = ?, face = ?, pubdate = ?, 
     quality = ?, \`desc\` = ?, duration = ?, aid = ?, download_link = ?,
     cid = ?, tname = ?, current_viewers = ?
     WHERE id = ?`,
    [
      videoInfo.title,
      videoInfo.pic || "",
      videoInfo.view || 0,
      videoInfo.danmaku || 0,
      videoInfo.like || 0,
      videoInfo.coin || 0,
      videoInfo.favorite || 0,
      videoInfo.share || 0,
      videoInfo.reply || 0,
      videoInfo.owner?.name || "未知",
      videoInfo.owner?.face || "",
      videoInfo.pubdate || "",
      videoInfo.quality || 80,
      videoInfo.description || "",
      videoInfo.duration || 0,
      videoInfo.aid || "",
      playUrl,
      videoInfo.cid || "",
      videoInfo.tname || "",
      videoInfo.current_viewers || 0,
      existingRecord.id
    ]
  );
  
  console.log(`🔄 已更新现有视频记录: ${videoInfo.title}`);
  
  return {
    id: existingRecord.id,
    updated: true,
    title: videoInfo.title,
    bvid: bvid,
    filePath: finalVideoPath,
    playUrl: playUrl,
    message: "视频已存在,仅更新数据库信息",
    downloadMode,
    qualityDesc: videoInfo.qualityDesc,
    skippedProcessing: true // 标记跳过了处理过程
  };
}

console.log(`🆕 开始处理新视频或重新处理: ${bvid}`);

// 2. 解析视频信息(完整解析用于下载)
const videoInfo = await parseVideoInfo(url, cookieString, quality);

// 3. 创建文件名和路径
const uniqueId = uuidv4().substring(0, 8);
const tempVideoFileName = `${videoInfo.bvid}_${uniqueId}_video.mp4`;
const tempAudioFileName = `${videoInfo.bvid}_${uniqueId}_audio.mp3`;
const tempOutputFileName = `${videoInfo.bvid}_${uniqueId}_temp.mp4`;
// finalFileName 已在前面声明过,这里不需要重复声明

const tempVideoPath = path.join(DOWNLOAD_DIR, tempVideoFileName);
const tempAudioPath = path.join(DOWNLOAD_DIR, tempAudioFileName);
const tempOutputPath = path.join(DOWNLOAD_DIR, tempOutputFileName);
// finalVideoPath 也已在前面声明过,这里不需要重复声明

// 4. 下载视频和音频
console.log(`📥 开始下载视频和音频...`);

const downloadPromises = [];

if (downloadMode === "video" || downloadMode === "auto") {
  downloadPromises.push(
    downloadFile(videoInfo.videoUrl, tempVideoPath, cookieString, (progress) => {
      console.log(`📹 视频下载进度: ${progress}%`);
    })
  );
}

if (downloadMode === "audio" || downloadMode === "auto") {
  downloadPromises.push(
    downloadFile(videoInfo.audioUrl, tempAudioPath, cookieString, (progress) => {
      console.log(`🎵 音频下载进度: ${progress}%`);
    })
  );
}

await Promise.all(downloadPromises);

// 5. 合并视频和音频(如果都下载了)
let tempFinalPath = tempOutputPath;
if (downloadMode === "auto" && fs.existsSync(tempVideoPath) && fs.existsSync(tempAudioPath)) {
  console.log(`🔧 开始合并视频和音频: ${finalFileName}`);
  await mergeVideoAndAudio(tempVideoPath, tempAudioPath, tempOutputPath, (progress) => {
    console.log(`🔧 合并进度: ${progress}%`);
  });
  
  // 清理临时文件
  try {
    fs.unlinkSync(tempVideoPath);
    fs.unlinkSync(tempAudioPath);
    console.log(`🗑️ 清理临时文件完成`);
  } catch (cleanupError) {
    console.warn(`⚠️ 清理临时文件失败:`, cleanupError.message);
  }
} else if (downloadMode === "video" && fs.existsSync(tempVideoPath)) {
  tempFinalPath = tempVideoPath;
} else if (downloadMode === "audio" && fs.existsSync(tempAudioPath)) {
  tempFinalPath = tempAudioPath;
}

// 6. 移动文件到最终目录
if (fs.existsSync(tempFinalPath)) {
  // 如果最终文件已存在,先删除
  if (fs.existsSync(finalVideoPath)) {
    fs.unlinkSync(finalVideoPath);
    console.log(`🗑️ 删除已存在的文件: ${finalFileName}`);
  }
  
  fs.renameSync(tempFinalPath, finalVideoPath);
  console.log(`📁 文件已移动到: ${finalVideoPath}`);
} else {
  throw new Error('处理后的视频文件不存在');
}

// 7. 生成播放地址 - 使用SERVER_HOST配置
const serverPort = process.env.PORT || 3000;
const serverHost = process.env.SERVER_HOST || 'localhost';
const playUrl = `http://${serverHost}:${serverPort}/api/video/download/${finalFileName}`;

// 8. 保存到数据库
const dbRecord = await saveOrUpdateVideoInDb(videoInfo, finalVideoPath, playUrl, userId, bilibiliAccountId);

return {
  ...dbRecord,
  message: "视频处理完成",
  downloadMode,
  qualityDesc: videoInfo.qualityDesc,
  playUrl: playUrl
};

} catch (error) { console.error(❌ 处理视频请求失败:, error); throw error; } }

/**

  • 批量处理视频
  • @param {Object} options - 批量处理选项
  • @returns {Promise} 批量处理结果 */ async function batchProcessVideos(options) { const { urls, userId, cookieString, quality = 80, downloadMode = "auto", bilibiliAccountId } = options;

const results = { success: [], failed: [], total: urls.length };

for (let i = 0; i < urls.length; i++) { const url = urls[i]; try { console.log(📦 批量处理进度: ${i + 1}/${urls.length} - ${url});

  const result = await processVideoRequest({
    url,
    userId,
    cookieString,
    quality,
    downloadMode,
    bilibiliAccountId
  });
  
  results.success.push({
    url,
    result,
    index: i + 1
  });
  
  // 添加延迟避免请求过于频繁
  if (i < urls.length - 1) {
    await new Promise(resolve => setTimeout(resolve, 2000));
  }
  
} catch (error) {
  console.error(`❌ 批量处理第 ${i + 1} 个视频失败:`, error.message);
  results.failed.push({
    url,
    error: error.message,
    index: i + 1
  });
}

}

return results; }

/**

  • 生成安全下载token
  • @param {string} fileName - 文件名
  • @param {string} userId - 用户ID
  • @param {number} expiresIn - 过期时间(秒),默认1小时
  • @returns {string} JWT token */ function generateDownloadToken(fileName, userId, expiresIn = 3600) { const payload = { fileName, userId, type: 'download', timestamp: Date.now() };

return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn }); }

/**

  • 验证下载token
  • @param {string} token - JWT token
  • @returns {object|null} 解码后的payload或null */ function verifyDownloadToken(token) { try { return jwt.verify(token, process.env.JWT_SECRET); } catch (error) { console.error('Token验证失败:', error.message); return null; } }

/**

  • 生成临时下载链接
  • @param {string} fileName - 文件名
  • @param {string} userId - 用户ID
  • @returns {object} 包含下载链接和token的对象 */ function generateSecureDownloadLink(fileName, userId) { const token = generateDownloadToken(fileName, userId, 3600); // 1小时有效期 const serverPort = process.env.PORT || 3000; const serverHost = process.env.SERVER_HOST || 'localhost';

return { downloadUrl: http://${serverHost}:${serverPort}/api/video/secure-download?token=${token}&file=${encodeURIComponent(fileName)}, token, expiresAt: new Date(Date.now() + 3600 * 1000).toISOString() }; }

/**

  • 检查用户是否有权限下载指定文件

  • @param {string} fileName - 文件名

  • @param {string} userId - 用户ID

  • @returns {Promise} 是否有权限 */ async function checkDownloadPermission(fileName, userId) { try { console.log(🔐 检查用户 ${userId} 对文件 ${fileName} 的下载权限);

    // 从文件名提取BVID const bvid = fileName.replace(/.(mp4|mp3)$/, '');

    // 通过user_videos关联表检查用户是否有权限访问该视频 const [userVideoRelations] = await db.execute( SELECT uv.relation_type, v.title, v.bvid FROM user_videos uv INNER JOIN videos v ON uv.video_id = v.id WHERE uv.user_id = ? AND v.bvid = ?, [userId, bvid] );

    if (userVideoRelations.length > 0) { const relation = userVideoRelations[0]; console.log(✅ 用户有权限下载,关系类型: ${getRelationTypeDesc(relation.relation_type)}); return true; }

    console.log(❌ 用户无权限下载该视频: ${bvid}); return false; } catch (error) { console.error('检查下载权限失败:', error); return false; } }

/**

  • 安全文件下载处理

  • @param {string} fileName - 文件名

  • @param {object} req - Express请求对象

  • @param {object} res - Express响应对象 */ async function handleSecureDownload(fileName, req, res) { try { const filePath = path.join(VIDEO_DIR, fileName);

    // 检查文件是否存在 if (!fs.existsSync(filePath)) { return res.status(404).json({ code: 404, message: '文件不存在' }); }

    // 获取文件信息 const stat = fs.statSync(filePath); const fileSize = stat.size;

    // 设置响应头,支持断点续传 res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', attachment; filename="${encodeURIComponent(fileName)}"); res.setHeader('Accept-Ranges', 'bytes'); res.setHeader('Content-Length', fileSize);

    // 处理Range请求(断点续传) const range = req.headers.range; if (range) { const parts = range.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const chunksize = (end - start) + 1;

    res.status(206); res.setHeader('Content-Range', bytes ${start}-${end}/${fileSize}); res.setHeader('Content-Length', chunksize);

    const stream = fs.createReadStream(filePath, { start, end }); stream.pipe(res); } else { // 完整文件下载 const stream = fs.createReadStream(filePath); stream.pipe(res); }

} catch (error) { console.error('安全下载处理失败:', error); res.status(500).json({ code: 500, message: '下载失败' }); } }

/**

  • 检查用户每日下载申请限制

  • @param {number} userId - 用户ID

  • @param {string} userRole - 用户权限等级

  • @param {Object} redis - Redis连接实例

  • @returns {Promise} 检查结果 */ async function checkDailyDownloadLimit(userId, userRole, redis) { try { // 根据用户权限等级设置每日限制 const dailyLimits = { '1': 1, // 1级权限:每天1个 '2': 10, // 2级权限:每天10个 '3': 100, // 3级权限:每天100个 '4': -1 // 4级权限:无限制 };

    const limit = dailyLimits[userRole] || 1; // 默认1级权限

    // 4级权限无限制 if (limit === -1) { return { allowed: true, remaining: -1 }; }

    // 获取今日申请次数的Redis键 const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD格式 const redisKey = download_requests:${userId}:${today};

    // 获取当前申请次数 const currentCount = await redis.get(redisKey) || 0; const remaining = limit - parseInt(currentCount);

    if (remaining <= 0) { const roleNames = { '1': '1级', '2': '2级', '3': '3级', '4': '4级' }; return { allowed: false, message: 您的${roleNames[userRole]}权限每日只能申请${limit}个视频下载权限,今日已达上限。明日00:00重置。, remaining: 0 }; }

    return { allowed: true, remaining }; } catch (error) { console.error('检查每日下载限制失败:', error); throw error; } }

/**

  • 增加用户每日下载申请计数

  • @param {number} userId - 用户ID

  • @param {Object} redis - Redis连接实例 */ async function incrementDailyDownloadCount(userId, redis) { try { const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD格式 const redisKey = download_requests:${userId}:${today};

    // 增加计数 await redis.incr(redisKey);

    // 设置过期时间到明日00:00 const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(0, 0, 0, 0); const secondsUntilMidnight = Math.floor((tomorrow.getTime() - Date.now()) / 1000);

    await redis.expire(redisKey, secondsUntilMidnight);

    console.log(📊 用户${userId}今日申请计数已更新,过期时间:${secondsUntilMidnight}秒后); } catch (error) { console.error('更新每日下载计数失败:', error); throw error; } }

/**

  • 添加用户视频关联关系(下载者)
  • @param {number} userId - 用户ID
  • @param {string} bvid - 视频BVID
  • @returns {Promise} 操作结果 */ async function addVideoDownloader(userId, bvid) { const redis = require('../../config/redis');

try { console.log(🔗 用户 ${userId} 请求添加视频 ${bvid} 的下载权限);

// 获取用户信息和权限等级
const [users] = await db.execute(
  "SELECT lv.role FROM loginverification lv WHERE lv.uid = ?",
  [userId]
);

if (users.length === 0) {
  throw new Error('用户不存在');
}

const userRole = users[0].role;

// 检查视频是否存在
const [videos] = await db.execute(
  "SELECT id, title FROM videos WHERE bvid = ?",
  [bvid]
);

if (videos.length === 0) {
  throw new Error('视频不存在');
}

const video = videos[0];

// 检查用户是否已有该视频的关联关系
const [existingRelations] = await db.execute(
  "SELECT relation_type FROM user_videos WHERE user_id = ? AND video_id = ?",
  [userId, video.id]
);

if (existingRelations.length > 0) {
  const existingType = existingRelations[0].relation_type;
  return {
    success: true,
    message: `您已经是该视频的${getRelationTypeDesc(existingType)},无需重复添加`,
    existingRelation: existingType
  };
}

// 检查是否为自己的视频(上传者或处理者不受限制)
const [ownerRelations] = await db.execute(
  "SELECT relation_type FROM user_videos WHERE user_id = ? AND video_id = ? AND relation_type IN ('uploader', 'processor')",
  [userId, video.id]
);

if (ownerRelations.length === 0) {
  // 不是自己的视频,需要检查每日申请限制
  const dailyLimit = await checkDailyDownloadLimit(userId, userRole, redis);
  if (!dailyLimit.allowed) {
    throw new Error(dailyLimit.message);
  }
}

// 添加下载者关系
await db.execute(
  "INSERT INTO user_videos (user_id, video_id, relation_type) VALUES (?, ?, 'downloader')",
  [userId, video.id]
);

// 如果不是自己的视频,增加今日申请计数
if (ownerRelations.length === 0) {
  await incrementDailyDownloadCount(userId, redis);
}

console.log(`✅ 成功添加下载者关系: 用户${userId} -> 视频${video.title}`);

return {
  success: true,
  message: '成功添加下载权限',
  videoTitle: video.title,
  bvid: bvid
};

} catch (error) { console.error('添加视频下载者关系失败:', error); throw error; } }

/**

  • 获取所有可下载的视频列表(公开列表)

  • @param {number} limit - 限制数量

  • @param {number} offset - 偏移量

  • @returns {Promise} 视频列表和总数 */ async function getAvailableVideos(limit = 20, offset = 0) { try { // 确保参数是有效的数字 const parsedLimit = parseInt(limit); const parsedOffset = parseInt(offset); const validLimit = Math.max(1, Math.min(100, isNaN(parsedLimit) ? 20 : parsedLimit)); const validOffset = Math.max(0, isNaN(parsedOffset) ? 0 : parsedOffset);

    console.log(📋 获取可下载视频列表,限制: ${validLimit}, 偏移: ${validOffset});

    // 获取总数 const [countResult] = await db.execute( "SELECT COUNT(*) as total FROM videos" ); const total = countResult[0].total;

    // 获取视频列表(简化查询) // 将参数转换为字符串以解决MySQL 8.0.22的已知问题 const limitStr = String(validLimit); const offsetStr = String(validOffset);

    console.log('SQL参数调试信息:'); console.log('limitStr:', limitStr, 'type:', typeof limitStr); console.log('offsetStr:', offsetStr, 'type:', typeof offsetStr);

    const [videos] = await db.execute( SELECT * FROM videos ORDER BY id DESC LIMIT ? OFFSET ?, [limitStr, offsetStr] );

    // 为每个视频获取用户数量和处理者信息 for (let video of videos) { // 获取用户数量 const [userCountResult] = await db.execute( 'SELECT COUNT(*) as count FROM user_videos WHERE video_id = ?', [video.id] ); video.user_count = userCountResult[0].count;

    // 获取处理者列表 const [processorsResult] = await db.execute( SELECT DISTINCT u.username FROM user_videos uv INNER JOIN user u ON uv.user_id = u.id WHERE uv.video_id = ? AND uv.relation_type = 'processor', [video.id] ); video.processors = processorsResult.map(p => p.username).join(','); }

    console.log(✅ 找到 ${videos.length} 个可下载视频);

    return { videos: videos.map(video => ({ ...video, processors: video.processors ? video.processors.split(',').slice(0, 3) : [] })), total, limit: validLimit, offset: validOffset, hasMore: validOffset + validLimit < total }; } catch (error) { console.error('获取可下载视频列表失败:', error); throw error; } }

module.exports = { parseVideoInfo, downloadFile, mergeVideoAndAudio, mergeVideoAndAudioDirect, getMergeTaskStatus, getMergeQueueStatus, saveOrUpdateVideoInDb, listAllVideos, getUserVideos, deleteVideo, processVideoRequest, batchProcessVideos, extractBVID, QUALITY_MAP, generateDownloadToken, verifyDownloadToken, generateSecureDownloadLink, checkDownloadPermission, handleSecureDownload, addVideoDownloader, getAvailableVideos, checkDailyDownloadLimit, incrementDailyDownloadCount, detectVideoCodec, // 队列管理相关 videoMergeQueue }; // app.js const express = require("express"); const cors = require("cors"); const http = require("http"); require("dotenv").config();

const { startHeartbeats } = require("./config/heartbeat"); const userRouter = require("./model/user/userRouters"); const videoRouter = require("./model/video/videoRouters"); // 【新增】导入视频路由 const dailyLimitRouter = require("./model/video/dailyLimitRoutes"); // 【新增】导入每日限制路由 const bilibiliRouter = require("./model/bilibili/bilibiliRouters"); // 【新增】导入B站路由

const app = express(); const server = http.createServer(app); const port = process.env.PORT || 3000;

// --- 中间件 --- app.use(cors()); // 启用 CORS app.use(express.json()); // 解析 JSON 请求体

// --- 静态文件服务 --- // 提供视频文件的直接访问服务 // 移除静态文件服务 - 改为安全的token验证下载方案 // const path = require("path"); // const serveIndex = require("serve-index"); // const videoDir = path.join(__dirname, "videos"); // app.use("/api/videos", express.static(videoDir), serveIndex(videoDir, { icons: true }));

// --- 路由 --- app.use("/api", userRouter); // 挂载用户路由,建议添加前缀 /user app.use("/api/video", videoRouter); // 【新增】挂载视频路由,统一前缀 /video app.use("/api/video", dailyLimitRouter); // 【新增】挂载每日限制路由,统一前缀 /video app.use("/api/bilibili", bilibiliRouter); // 【新增】挂载B站路由,统一前缀 /bilibili

// --- 启动服务 --- startHeartbeats(); // 启动数据库和 Redis 的心跳检测

server.listen(port, "0.0.0.0", () => { console.log(✅ 服务器已成功启动,正在监听端口:http://0.0.0.0:${port}); });

项目介绍

Source repository mirror for OpenHarmony Bench release snapshot.

定制我的领域