video-proxy.js 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. const { Readable } = require('stream');
  2. const { pipeline } = require('stream/promises');
  3. const { Agent } = require('undici');
  4. const CONNECT_TIMEOUT_MS = 30_000;
  5. const MAX_ATTEMPTS = 3;
  6. const RETRY_DELAYS_MS = [300, 800];
  7. const videoProxyAgent = new Agent({
  8. connect: {
  9. timeout: CONNECT_TIMEOUT_MS
  10. }
  11. });
  12. const PASSTHROUGH_HEADERS = [
  13. 'content-type',
  14. 'content-length',
  15. 'content-range',
  16. 'accept-ranges',
  17. 'etag',
  18. 'last-modified',
  19. 'cache-control'
  20. ];
  21. function sleep(ms) {
  22. return new Promise(resolve => setTimeout(resolve, ms));
  23. }
  24. function networkErrorCode(error) {
  25. return error?.cause?.code || error?.code || '';
  26. }
  27. function isRetryableNetworkError(error) {
  28. return [
  29. 'UND_ERR_CONNECT_TIMEOUT',
  30. 'UND_ERR_SOCKET',
  31. 'ECONNRESET',
  32. 'ETIMEDOUT',
  33. 'ENETUNREACH',
  34. 'EHOSTUNREACH'
  35. ].includes(networkErrorCode(error));
  36. }
  37. async function fetchVideoWithRetry(url, init, deps) {
  38. const fetchImpl = deps.fetchImpl || fetch;
  39. const maxAttempts = deps.maxAttempts || MAX_ATTEMPTS;
  40. let lastError = null;
  41. for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
  42. try {
  43. return await fetchImpl(url, init);
  44. } catch (error) {
  45. lastError = error;
  46. if (!isRetryableNetworkError(error) || attempt >= maxAttempts || init.signal?.aborted) {
  47. throw error;
  48. }
  49. console.warn(
  50. `[video-proxy] connect attempt ${attempt}/${maxAttempts} failed: ${error.message}; cause=${networkErrorCode(error)}`
  51. );
  52. await (deps.sleep || sleep)(RETRY_DELAYS_MS[Math.min(attempt - 1, RETRY_DELAYS_MS.length - 1)]);
  53. }
  54. }
  55. throw lastError || new Error('视频代理连接失败');
  56. }
  57. function registerVideoProxyRoutes(app, deps) {
  58. app.get('/api/video-proxy', async (req, res) => {
  59. const { url, filename = 'douyin-video.mp4', download } = req.query;
  60. if (!url || typeof url !== 'string') {
  61. return res.status(400).json({ error: '缺少 url 参数' });
  62. }
  63. if (!deps.isSafeRemoteUrl(url)) {
  64. return res.status(400).json({ error: '无效的视频地址' });
  65. }
  66. const abortController = new AbortController();
  67. const abortUpstream = () => {
  68. if (!res.writableEnded) abortController.abort();
  69. };
  70. res.once('close', abortUpstream);
  71. try {
  72. const upstreamHeaders = {
  73. 'Accept': req.headers.accept || '*/*',
  74. 'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0',
  75. 'Referer': 'https://www.douyin.com/',
  76. 'Origin': 'https://www.douyin.com'
  77. };
  78. if (req.headers.range) {
  79. upstreamHeaders.Range = req.headers.range;
  80. }
  81. const response = await fetchVideoWithRetry(url, {
  82. method: 'GET',
  83. headers: upstreamHeaders,
  84. redirect: 'follow',
  85. dispatcher: deps.dispatcher || videoProxyAgent,
  86. signal: abortController.signal
  87. }, deps);
  88. if (!response.ok && response.status !== 206) {
  89. const errorText = await response.text().catch(() => '');
  90. return res.status(response.status).json({
  91. error: '远程视频请求失败',
  92. status: response.status,
  93. detail: errorText
  94. });
  95. }
  96. res.status(response.status);
  97. for (const headerName of PASSTHROUGH_HEADERS) {
  98. const value = response.headers.get(headerName);
  99. if (value) res.setHeader(headerName, value);
  100. }
  101. if (!response.headers.get('content-type')) {
  102. res.setHeader('Content-Type', 'video/mp4');
  103. }
  104. res.setHeader(
  105. 'Content-Disposition',
  106. download === '1'
  107. ? `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`
  108. : `inline; filename*=UTF-8''${encodeURIComponent(filename)}`
  109. );
  110. if (!response.body) {
  111. return res.end();
  112. }
  113. await pipeline(Readable.fromWeb(response.body), res);
  114. } catch (error) {
  115. if (abortController.signal.aborted && res.destroyed) {
  116. return;
  117. }
  118. const detail = `${error.message}${networkErrorCode(error) ? `; cause=${networkErrorCode(error)}` : ''}`;
  119. console.error('[video-proxy] request failed:', detail);
  120. if (!res.headersSent) {
  121. res.status(500).json({ error: `视频代理失败: ${detail}` });
  122. } else if (!res.destroyed) {
  123. res.destroy();
  124. }
  125. } finally {
  126. res.off('close', abortUpstream);
  127. }
  128. });
  129. }
  130. module.exports = {
  131. registerVideoProxyRoutes,
  132. fetchVideoWithRetry,
  133. isRetryableNetworkError
  134. };