YvYang's Blog

使用CloudFlare代理WebDav播放音乐

3.9千字 预计阅读 16 分钟 0 次浏览

前言

最近下载了很多音乐,上传到网盘后就想着能不能引用到网站上,正好网盘支持WebDav挂载,没有服务器就只能使用CouldFlare Workers来实现。

教程

有两种方案:有网页UI和无UI,前者方便在线播放,后者访问速度更佳,读者可以视需求选择

有网页UI版本

创建cloudfalre worker

登录CloudFlare,创建Workers,选择HelloWorld模版
覆盖式写入以下代码:

JavaScript
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const path = url.pathname;
    
    const allowedOrigins = env.ALLOWED_ORIGINS ? env.ALLOWED_ORIGINS.split(',').map(s => s.trim()) : ['*'];
    const debugMode = env.DEBUG_MODE === 'true';
 
    // 1. 根路径显示测试页面
    if (path === "/" || path === "/test") {
      const origin = request.headers.get('Origin') || '';
      if (origin && !isOriginAllowed(origin, allowedOrigins) && !allowedOrigins.includes('*')) {
        return new Response("Forbidden: Origin not allowed", { status: 403 });
      }
      return new Response(getTestPage(), {
        headers: { 
          "Content-Type": "text/html; charset=utf-8",
          ...getCORSHeaders(origin, allowedOrigins)
        },
      });
    }
 
    // 2. 调试接口
    if (path === "/api/debug") {
      return await debugInfo(env, url, request);
    }
 
    // 3. 测试 WebDAV 连接
    if (path === "/api/test-connection") {
      const origin = request.headers.get('Origin') || '';
      if (origin && !isOriginAllowed(origin, allowedOrigins) && !allowedOrigins.includes('*')) {
        return new Response("Forbidden", { status: 403 });
      }
      return await testConnection(env, origin, allowedOrigins);
    }
 
    // 4. 测试文件
    if (path === "/api/test-file") {
      const origin = request.headers.get('Origin') || '';
      if (origin && !isOriginAllowed(origin, allowedOrigins) && !allowedOrigins.includes('*')) {
        return new Response("Forbidden", { status: 403 });
      }
      const filePath = url.searchParams.get("file") || "/test.mp3";
      return await testFile(request, env, filePath, origin, allowedOrigins, debugMode);
    }
 
    // 5. 获取文件列表
    if (path === "/api/list") {
      const origin = request.headers.get('Origin') || '';
      if (origin && !isOriginAllowed(origin, allowedOrigins) && !allowedOrigins.includes('*')) {
        return new Response("Forbidden", { status: 403 });
      }
      const dirPath = url.searchParams.get("path") || "/";
      return await getFileList(env, dirPath, origin, allowedOrigins);
    }
 
    // 6. OPTIONS 预检
    if (request.method === "OPTIONS") {
      const origin = request.headers.get('Origin') || '';
      return new Response(null, {
        headers: {
          ...getCORSHeaders(origin, allowedOrigins),
          "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS, PROPFIND",
          "Access-Control-Allow-Headers": "Content-Type, Range, Authorization, Depth",
          "Access-Control-Max-Age": "86400",
        },
      });
    }
 
    // 7. 只允许 GET 和 HEAD
    if (request.method !== "GET" && request.method !== "HEAD") {
      return new Response("Method not allowed", { status: 405 });
    }
 
    // 8. 代理音乐请求
    const origin = request.headers.get('Origin') || '';
    if (origin && !isOriginAllowed(origin, allowedOrigins) && !allowedOrigins.includes('*')) {
      return new Response("Forbidden: Origin not allowed", { status: 403 });
    }
    return await proxyRequest(request, env, path, origin, allowedOrigins, debugMode);
  },
};
 
// ============== 辅助函数 ==============
 
function isOriginAllowed(origin, allowedOrigins) {
  if (!origin) return true;
  if (allowedOrigins.includes('*')) return true;
  return allowedOrigins.some(allowed => {
    const allowedTrimmed = allowed.trim().toLowerCase();
    const originLower = origin.toLowerCase();
    return originLower === allowedTrimmed || 
           originLower.endsWith('.' + allowedTrimmed.replace(/^https?:\/\//, ''));
  });
}
 
function getCORSHeaders(origin, allowedOrigins) {
  if (allowedOrigins.includes('*')) {
    return {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Expose-Headers": "Content-Range, Content-Length, Accept-Ranges, X-Debug-Info",
      "Vary": "Origin",
    };
  }
  if (origin && isOriginAllowed(origin, allowedOrigins)) {
    return {
      "Access-Control-Allow-Origin": origin,
      "Access-Control-Expose-Headers": "Content-Range, Content-Length, Accept-Ranges, X-Debug-Info",
      "Vary": "Origin",
    };
  }
  return {};
}
 
// ============== 关键:路径规范化函数 ==============
/**
 * 规范化请求路径,避免与 WEBDAV_URL 的路径前缀重复
 * 例如:
 * - WEBDAV_URL = https://pan.xxbyq.net/dav
 * - 请求路径 = /dav/音乐/歌.mp3
 * - 返回 = /音乐/歌.mp3 (移除重复的 /dav)
 */
function normalizePath(requestPath, webdavUrl) {
  try {
    const urlObj = new URL(webdavUrl);
    const webdavPath = urlObj.pathname.replace(/\/$/, ""); // 移除末尾 /
    
    // 如果 WEBDAV_URL 有路径前缀(如 /dav),且请求路径也以它开头,则移除
    if (webdavPath && webdavPath !== '/' && requestPath.startsWith(webdavPath + '/')) {
      return requestPath.substring(webdavPath.length);
    }
    return requestPath;
  } catch (e) {
    return requestPath;
  }
}
 
// ============== 调试接口 ==============
async function debugInfo(env, url, request) {
  const file = url.searchParams.get('file') || url.pathname;
  const webdavBase = env.WEBDAV_URL ? env.WEBDAV_URL.replace(/\/$/, "") : '';
  const normalizedPath = normalizePath(file, env.WEBDAV_URL);
  const computedTargetUrl = webdavBase + normalizedPath;
  
  const debug = {
    requestUrl: url.href,
    requestPath: url.pathname,
    webdavUrl: env.WEBDAV_URL,
    originalFilePath: file,
    normalizedFilePath: normalizedPath,
    computedTargetUrl: computedTargetUrl,
    timestamp: new Date().toISOString(),
  };
  
  return new Response(JSON.stringify(debug, null, 2), {
    headers: { "Content-Type": "application/json" },
  });
}
 
// ============== 测试页面 HTML ==============
function getTestPage() {
  return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Worker 调试面板</title>
  <style>
    body { font-family: monospace; max-width: 900px; margin: 20px auto; padding: 20px; background: #1a1a2e; color: #eee; }
    .card { background: #16213e; padding: 20px; margin: 15px 0; border-radius: 8px; border: 1px solid #0f3460; }
    h2 { color: #e94560; margin-top: 0; }
    .btn { background: #e94560; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; margin: 5px; }
    .btn:hover { background: #c73e54; }
    pre { background: #0f0f23; padding: 15px; border-radius: 4px; overflow-x: auto; font-size: 12px; max-height: 400px; }
    .success { color: #00d9a5; }
    .error { color: #ff6b6b; }
    .warn { color: #ffc107; }
    input { padding: 8px; background: #0f0f23; border: 1px solid #0f3460; color: #eee; border-radius: 4px; width: 400px; }
  </style>
</head>
<body>
  <h1>🔧 Worker 路径调试面板</h1>
  
  <div class="card">
    <h2>🔍 路径调试</h2>
    <p>输入文件路径,查看 Worker 实际请求的 WebDAV URL:</p>
    <input type="text" id="testPath" placeholder="/dav/xxx.mp3" value="/dav/きらめく湖畔 - 加藤達也.mp3">
    <button class="btn" onclick="debugPath()">🔎 查看实际请求 URL</button>
    <pre id="debugResult">点击按钮查看结果...</pre>
  </div>
 
  <div class="card">
    <h2>📁 文件列表</h2>
    <input type="text" id="listPath" placeholder="目录路径" value="/">
    <button class="btn" onclick="loadList()">📂 加载列表</button>
    <pre id="listResult">-</pre>
  </div>
 
  <div class="card">
    <h2>🎵 播放测试</h2>
    <input type="text" id="playPath" placeholder="文件路径" value="/dav/きらめく湖畔 - 加藤達也.mp3">
    <button class="btn" onclick="testPlay()">▶️ 测试播放</button>
    <div id="playResult"></div>
    <audio controls id="audio" style="width:100%;margin-top:10px;display:none;"></audio>
  </div>
 
  <script>
    const workerUrl = window.location.origin;
    
    async function debugPath() {
      const path = document.getElementById('testPath').value;
      const result = document.getElementById('debugResult');
      result.textContent = '请求中...';
      try {
        const res = await fetch(workerUrl + '/api/debug?file=' + encodeURIComponent(path));
        const data = await res.json();
        result.textContent = JSON.stringify(data, null, 2);
      } catch (e) { result.textContent = '❌ 错误:' + e.message; }
    }
    
    async function loadList() {
      const path = document.getElementById('listPath').value;
      const result = document.getElementById('listResult');
      result.textContent = '加载中...';
      try {
        const res = await fetch(workerUrl + '/api/list?path=' + encodeURIComponent(path));
        const data = await res.json();
        result.textContent = JSON.stringify(data, null, 2);
      } catch (e) { result.textContent = '❌ 错误:' + e.message; }
    }
    
    async function testPlay() {
      const path = document.getElementById('playPath').value;
      const result = document.getElementById('playResult');
      const audio = document.getElementById('audio');
      result.innerHTML = '<span class="warn">测试中...</span>';
      audio.style.display = 'none';
      try {
        const res = await fetch(workerUrl + path, { method: 'HEAD' });
        if (res.status === 200 || res.status === 206) {
          result.innerHTML = '<span class="success">✅ 文件可访问!状态码:' + res.status + '</span>';
          audio.src = workerUrl + path;
          audio.style.display = 'block';
        } else {
          result.innerHTML = '<span class="error">❌ 状态码:' + res.status + '</span>';
        }
      } catch (e) { result.innerHTML = '<span class="error">❌ 请求失败:' + e.message + '</span>'; }
    }
    window.onload = debugPath;
  </script>
</body>
</html>
  `;
}
 
// ============== 测试 WebDAV 连接 ==============
async function testConnection(env, origin, allowedOrigins) {
  const result = { timestamp: new Date().toISOString(), envStatus: false, connectionStatus: false, error: null };
  if (!env.WEBDAV_URL || !env.WEBDAV_USER || !env.WEBDAV_PASS) {
    result.error = "环境变量未配置完整";
    return new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json", ...getCORSHeaders(origin, allowedOrigins) } });
  }
  result.envStatus = true;
  try {
    const auth = btoa(`${env.WEBDAV_USER}:${env.WEBDAV_PASS}`);
    const res = await fetch(env.WEBDAV_URL, { method: "PROPFIND", headers: { Authorization: `Basic ${auth}`, Depth: "0" } });
    result.connectionStatus = res.status === 207 || res.status === 200;
    result.httpStatus = res.status;
    if (!result.connectionStatus) result.error = `HTTP ${res.status}`;
  } catch (e) { result.error = e.message; }
  return new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json", ...getCORSHeaders(origin, allowedOrigins) } });
}
 
// ============== 测试文件访问 ==============
async function testFile(request, env, filePath, origin, allowedOrigins, debugMode) {
  const result = { filePath, status: 0, headers: {}, supportsRange: false, targetUrl: '' };
  try {
    const webdavBase = env.WEBDAV_URL.replace(/\/$/, "");
    const normalizedPath = normalizePath(filePath, env.WEBDAV_URL); // ✅ 关键:规范化路径
    const targetUrl = webdavBase + normalizedPath;
    result.targetUrl = targetUrl;
    const auth = btoa(`${env.WEBDAV_USER}:${env.WEBDAV_PASS}`);
    const res = await fetch(targetUrl, { method: "HEAD", headers: { Authorization: `Basic ${auth}`, Range: "bytes=0-1" } });
    result.status = res.status;
    result.supportsRange = res.status === 206;
    res.headers.forEach((v, k) => { if (["content-type", "content-length", "content-range", "accept-ranges"].includes(k.toLowerCase())) result.headers[k] = v; });
    if (debugMode) result.debug = { targetUrl, normalizedPath };
  } catch (e) { result.error = e.message; }
  return new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json", ...getCORSHeaders(origin, allowedOrigins) } });
}
 
// ============== 获取文件列表 ==============
async function getFileList(env, dirPath, origin, allowedOrigins) {
  const result = { path: dirPath, files: [], error: null };
  if (!env.WEBDAV_URL || !env.WEBDAV_USER || !env.WEBDAV_PASS) {
    result.error = "环境变量未配置";
    return new Response(JSON.stringify(result), { status: 500, headers: { "Content-Type": "application/json", ...getCORSHeaders(origin, allowedOrigins) } });
  }
  try {
    const webdavBase = env.WEBDAV_URL.replace(/\/$/, "");
    const normalizedDir = normalizePath(dirPath, env.WEBDAV_URL); // ✅ 规范化目录路径
    const targetUrl = webdavBase + normalizedDir;
    const auth = btoa(`${env.WEBDAV_USER}:${env.WEBDAV_PASS}`);
    const propfindBody = `<?xml version="1.0" encoding="utf-8"?><D:propfind xmlns:D="DAV:"><D:prop><D:displayname/><D:getcontentlength/><D:getcontenttype/><D:resourcetype/><D:getlastmodified/></D:prop></D:propfind>`;
    const res = await fetch(targetUrl, { method: "PROPFIND", headers: { Authorization: `Basic ${auth}`, Depth: "1", "Content-Type": "application/xml" }, body: propfindBody });
    if (res.status !== 207) { result.error = `WebDAV 返回状态码:${res.status}`; result.targetUrl = targetUrl; return new Response(JSON.stringify(result), { status: res.status, headers: { "Content-Type": "application/json", ...getCORSHeaders(origin, allowedOrigins) } }); }
    const xmlText = await res.text();
    const files = parseWebDAVXML(xmlText, dirPath, env.WEBDAV_URL);
    result.files = files.filter(f => f.path !== dirPath);
  } catch (e) { result.error = e.message; }
  return new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json", ...getCORSHeaders(origin, allowedOrigins) } });
}
 
// ============== 解析 WebDAV XML ==============
function parseWebDAVXML(xmlText, requestPath, webdavUrl) {
  const files = [];
  const responseRegex = /<D:response[^>]*>([\s\S]*?)<\/D:response>/gi;
  let responseMatch;
  while ((responseMatch = responseRegex.exec(xmlText)) !== null) {
    const responseBlock = responseMatch[1];
    const hrefMatch = responseBlock.match(/<D:href[^>]*>([^<]+)<\/D:href>/i);
    if (!hrefMatch || !hrefMatch[1]) continue;
    let href = decodeURIComponent(hrefMatch[1].trim());
    try { const urlObj = new URL(href, 'http://localhost'); href = urlObj.pathname; } catch (e) {}
    // ✅ 规范化路径:移除 WEBDAV_URL 中的路径前缀
    try {
      const urlObj = new URL(webdavUrl);
      const webdavPath = urlObj.pathname.replace(/\/$/, "");
      if (webdavPath && webdavPath !== '/' && href.startsWith(webdavPath + '/')) {
        href = href.substring(webdavPath.length);
      }
    } catch (e) {}
    if (!href.startsWith('/')) href = '/' + href;
    const displayNameMatch = responseBlock.match(/<D:displayname[^>]*>([^<]*)<\/D:displayname>/i);
    let displayName = displayNameMatch ? displayNameMatch[1].trim() : href.split('/').filter(p => p).pop() || '/';
    const isCollection = /<D:collection[^>]*\/>|<D:collection[^>]*><\/D:collection>/i.test(responseBlock);
    const contentLengthMatch = responseBlock.match(/<D:getcontentlength[^>]*>([^<]+)<\/D:getcontentlength>/i);
    const size = contentLengthMatch ? parseInt(contentLengthMatch[1]) : null;
    const lastModifiedMatch = responseBlock.match(/<D:getlastmodified[^>]*>([^<]+)<\/D:getlastmodified>/i);
    const modified = lastModifiedMatch ? lastModifiedMatch[1].trim() : null;
    const contentTypeMatch = responseBlock.match(/<D:getcontenttype[^>]*>([^<]+)<\/D:getcontenttype>/i);
    const contentType = contentTypeMatch ? contentTypeMatch[1].trim() : null;
    files.push({ name: displayName, path: href, type: isCollection ? 'directory' : 'file', size, modified, contentType });
  }
  return files;
}
 
// ============== 代理音乐请求 ==============
async function proxyRequest(request, env, path, origin, allowedOrigins, debugMode) {
  if (!env.WEBDAV_URL || !env.WEBDAV_USER || !env.WEBDAV_PASS) return new Response("环境变量未配置", { status: 500 });
  const webdavBase = env.WEBDAV_URL.replace(/\/$/, "");
  const normalizedPath = normalizePath(path, env.WEBDAV_URL); // ✅ 关键:规范化路径
  const targetUrl = webdavBase + normalizedPath;
  const headers = new Headers();
  const auth = btoa(`${env.WEBDAV_USER}:${env.WEBDAV_PASS}`);
  headers.set("Authorization", `Basic ${auth}`);
  if (request.headers.has("Range")) headers.set("Range", request.headers.get("Range"));
  if (request.headers.has("If-Range")) headers.set("If-Range", request.headers.get("If-Range"));
  try {
    const response = await fetch(targetUrl, { method: request.method, headers });
    const newHeaders = new Headers(response.headers);
    Object.assign(newHeaders, getCORSHeaders(origin, allowedOrigins));
    if (debugMode) newHeaders.set("X-Debug-Target-Url", targetUrl);
    return new Response(response.body, { status: response.status, statusText: response.statusText, headers: newHeaders });
  } catch (e) {
    return new Response("Proxy Error: " + e.message, { status: 502, headers: { "X-Debug-Target-Url": targetUrl } });
  }
}

配置境变量

在Worker设置中,添加以下环境变量:

  • ALLOWED_ORIGINS: 跨域设置
  • WEBDAY_PASS: 密码
  • WEBDAV_URL: 连接地址
  • WEBDAY _USER: 账号

无UI版本

编辑代码

在worker中覆盖写入一下内容

JavaScript
// Cloudflare Worker代码 - webdav-proxy.js
// 保存为index.js并部署到Cloudflare Workers
 
// WebDAV服务器配置(通过环境变量设置)
// 需要在Workers设置中添加以下环境变量:
// WEBDAV_URL: 您的WebDAV服务器地址(如https://example.com/dav)
// WEBDAV_USERNAME: WebDAV用户名
// WEBDAV_PASSWORD: WebDAV密码
 
// 允许的音频文件扩展名
const ALLOWED_EXTENSIONS = [
  'mp3', 'm4a', 'ogg', 'wav', 'flac', 'aac', 'opus', 'webm'
];
 
// 允许的CORS来源
const ALLOWED_ORIGINS = [
  'http://localhost:3000',
  'http://localhost:8080',
  'https://your-static-site.com'  // 替换为您的静态网站域名
];
 
export default {
  async fetch(request, env) {
    // 处理CORS预检请求
    if (request.method === 'OPTIONS') {
      return handleCORS(request);
    }
    
    const url = new URL(request.url);
    const path = url.pathname;
    
    // API路由处理
    if (path.startsWith('/api/')) {
      return handleAPIRequest(request, env, url);
    }
    
    // 静态文件请求(用于直接播放)
    if (path.startsWith('/stream/')) {
      return handleStreamRequest(request, env, url);
    }
    
    // 默认返回API信息
    return new Response(JSON.stringify({
      name: 'WebDAV音乐代理服务',
      version: '1.0',
      endpoints: {
        list: '/api/list?path=文件夹路径',
        stream: '/stream/文件路径',
        search: '/api/search?q=搜索词',
        cover: '/api/cover?path=音乐文件路径'
      }
    }), {
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': getOriginHeader(request)
      }
    });
  }
};
 
// 处理CORS
function handleCORS(request) {
  const origin = request.headers.get('Origin') || '*';
  return new Response(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
      'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      'Access-Control-Max-Age': '86400'
    }
  });
}
 
// 获取允许的Origin
function getOriginHeader(request) {
  const origin = request.headers.get('Origin');
  if (origin && ALLOWED_ORIGINS.includes(origin)) {
    return origin;
  }
  return ALLOWED_ORIGINS[0];
}
 
// 处理API请求
async function handleAPIRequest(request, env, url) {
  const path = url.pathname;
  const searchParams = url.searchParams;
  
  try {
    // 获取文件列表
    if (path === '/api/list') {
      const dirPath = searchParams.get('path') || '/';
      return await listFiles(env, dirPath);
    }
    
    // 搜索文件
    if (path === '/api/search') {
      const query = searchParams.get('q') || '';
      return await searchFiles(env, query);
    }
    
    // 获取专辑封面
    if (path === '/api/cover') {
      const filePath = searchParams.get('path') || '';
      return await getAlbumCover(env, filePath);
    }
    
    return new Response(JSON.stringify({ error: 'API endpoint not found' }), {
      status: 404,
      headers: { 'Content-Type': 'application/json' }
    });
    
  } catch (error) {
    return new Response(JSON.stringify({ 
      error: error.message,
      stack: env.WORKER_ENV === 'development' ? error.stack : undefined
    }), {
      status: 500,
      headers: { 
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': getOriginHeader(request)
      }
    });
  }
}
 
// 处理流媒体请求
async function handleStreamRequest(request, env, url) {
  const filePath = url.pathname.replace('/stream/', '');
  
  if (!filePath) {
    return new Response(JSON.stringify({ error: 'File path is required' }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' }
    });
  }
  
  try {
    // 检查文件扩展名
    const ext = filePath.split('.').pop().toLowerCase();
    if (!ALLOWED_EXTENSIONS.includes(ext)) {
      return new Response(JSON.stringify({ error: 'File type not allowed' }), {
        status: 403,
        headers: { 'Content-Type': 'application/json' }
      });
    }
    
    // 构建WebDAV URL
    const webdavUrl = `${env.WEBDAV_URL}/${filePath}`;
    
    // 创建认证头
    const auth = btoa(`${env.WEBDAV_USERNAME}:${env.WEBDAV_PASSWORD}`);
    
    // 获取文件范围(支持断点续传)
    const range = request.headers.get('Range');
    const headers = {
      'Authorization': `Basic ${auth}`,
      'Accept': '*/*',
      'User-Agent': 'WebDAV-Music-Player/1.0'
    };
    
    if (range) {
      headers['Range'] = range;
    }
    
    // 发起请求
    const response = await fetch(webdavUrl, { headers });
    
    if (!response.ok) {
      throw new Error(`WebDAV request failed: ${response.status} ${response.statusText}`);
    }
    
    // 确定内容类型
    const contentType = getContentType(ext) || response.headers.get('Content-Type') || 'audio/mpeg';
    
    // 返回响应
    const responseHeaders = {
      'Content-Type': contentType,
      'Content-Length': response.headers.get('Content-Length'),
      'Accept-Ranges': 'bytes',
      'Access-Control-Allow-Origin': getOriginHeader(request),
      'Access-Control-Expose-Headers': 'Content-Length, Content-Range',
      'Cache-Control': 'public, max-age=31536000'
    };
    
    // 处理范围请求
    if (response.status === 206) {
      responseHeaders['Content-Range'] = response.headers.get('Content-Range');
    }
    
    return new Response(response.body, {
      status: response.status,
      headers: responseHeaders
    });
    
  } catch (error) {
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
      headers: { 
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': getOriginHeader(request)
      }
    });
  }
}
 
// 获取文件列表
async function listFiles(env, dirPath) {
  // 使用PROPFIND方法获取目录列表
  const webdavUrl = `${env.WEBDAV_URL}${dirPath}`;
  const auth = btoa(`${env.WEBDAV_USERNAME}:${env.WEBDAV_PASSWORD}`);
  
  const response = await fetch(webdavUrl, {
    method: 'PROPFIND',
    headers: {
      'Authorization': `Basic ${auth}`,
      'Depth': '1',
      'Content-Type': 'application/xml; charset=utf-8'
    },
    body: `<?xml version="1.0" encoding="utf-8"?>
           <propfind xmlns="DAV:">
             <prop>
               <displayname/>
               <resourcetype/>
               <getcontentlength/>
               <getlastmodified/>
               <getcontenttype/>
             </prop>
           </propfind>`
  });
  
  if (!response.ok) {
    throw new Error(`Failed to list directory: ${response.status}`);
  }
  
  const xmlText = await response.text();
  const files = parseWebDAVResponse(xmlText, dirPath);
  
  return new Response(JSON.stringify({
    path: dirPath,
    files: files
  }), {
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*'
    }
  });
}
 
// 解析WebDAV XML响应
function parseWebDAVResponse(xmlText, currentPath) {
  const files = [];
  const parser = new DOMParser();
  const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
  
  // 获取所有响应
  const responses = xmlDoc.getElementsByTagNameNS('DAV:', 'response');
  
  for (let response of responses) {
    const href = response.getElementsByTagNameNS('DAV:', 'href')[0]?.textContent || '';
    const displayName = response.getElementsByTagNameNS('DAV:', 'displayname')[0]?.textContent || '';
    const resourceType = response.getElementsByTagNameNS('DAV:', 'resourcetype')[0];
    const contentType = response.getElementsByTagNameNS('DAV:', 'getcontenttype')[0]?.textContent || '';
    const contentLength = response.getElementsByTagNameNS('DAV:', 'getcontentlength')[0]?.textContent || '0';
    const lastModified = response.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || '';
    
    // 跳过当前目录
    if (href.endsWith('/') && href.split('/').filter(Boolean).pop() === currentPath.split('/').filter(Boolean).pop()) {
      continue;
    }
    
    const isDirectory = resourceType.getElementsByTagNameNS('DAV:', 'collection').length > 0;
    const name = displayName || href.split('/').filter(Boolean).pop() || '';
    
    // 跳过隐藏文件
    if (name.startsWith('.')) {
      continue;
    }
    
    files.push({
      name: name,
      path: href,
      isDirectory: isDirectory,
      size: parseInt(contentLength) || 0,
      type: isDirectory ? 'directory' : (contentType || getContentType(name.split('.').pop())),
      modified: lastModified
    });
  }
  
  return files;
}
 
// 搜索文件
async function searchFiles(env, query) {
  // 由于WebDAV没有原生搜索,我们需要递归遍历
  // 这里简化实现,只搜索根目录下的音乐文件
  const allFiles = await getAllAudioFiles(env, '/');
  const results = allFiles.filter(file => 
    file.name.toLowerCase().includes(query.toLowerCase()) ||
    file.path.toLowerCase().includes(query.toLowerCase())
  );
  
  return new Response(JSON.stringify({
    query: query,
    results: results
  }), {
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*'
    }
  });
}
 
// 递归获取所有音频文件(简化版)
async function getAllAudioFiles(env, path) {
  const files = [];
  
  // 获取当前目录文件
  const dirFiles = await getDirectoryFiles(env, path);
  
  for (const file of dirFiles) {
    if (file.isDirectory) {
      // 递归获取子目录
      const subFiles = await getAllAudioFiles(env, file.path);
      files.push(...subFiles);
    } else if (ALLOWED_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(`.${ext}`))) {
      files.push(file);
    }
  }
  
  return files;
}
 
// 获取目录文件(简化版,不使用PROPFIND)
async function getDirectoryFiles(env, path) {
  // 这里简化实现,实际应该使用上面的listFiles逻辑
  return [];
}
 
// 获取专辑封面(从MP3文件提取)
async function getAlbumCover(env, filePath) {
  // 简化实现,返回默认封面
  // 实际应解析MP3文件的ID3标签获取封面
  
  return new Response(JSON.stringify({
    path: filePath,
    cover: null,
    message: 'Album cover extraction not implemented in this example'
  }), {
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*'
    }
  });
}
 
// 根据扩展名获取内容类型
function getContentType(ext) {
  const types = {
    'mp3': 'audio/mpeg',
    'm4a': 'audio/mp4',
    'ogg': 'audio/ogg',
    'wav': 'audio/wav',
    'flac': 'audio/flac',
    'aac': 'audio/aac',
    'opus': 'audio/opus',
    'webm': 'audio/webm'
  };
  
  return types[ext.toLowerCase()] || 'application/octet-stream';
}

配置环境变量

在Worker设置中,添加以下环境变量:

  • WEBDAV_URL: 您的WebDAV服务器地址
  • WEBDAV_USERNAME: WebDAV用户名
  • WEBDAV_PASSWORD: WebDAV密码
  • WORKER_ENV: 设置为 “production” 或 “development”

添加自定义域名

Workers的域名在国内的访问速度很不理想,所以需要添加自定义域名

版本使用方法
无UI通过域名/stream/[文件名带后缀]访问测试
有UI把完整的文件名输入到播放测试的输入框里,点击测试播放解析完成后就可以完整试听

注意:目前还有一些Bug(但是懒得修了),不影响使用

作者自己部署了示例网站 有UI版本 可以试用

引入方式

在文章中使用如下方式进行引入

Markdown
<audio controls preload="metadata">
  <source src="链接" type="audio/mpeg">
  <!-- /dav不一定要写,建议尝试一下你网盘是什么类型的WebDav -->
  您的浏览器不支持 audio 标签。
</audio>

Butterfly主题还可以使用标签

Markdown
{% note info solid %}
**自定义文本**
<audio src="链接" controls style="width: 100%;"></audio>
{% endnote %}

链接部分

版本内容
无UI域名/stream/[文件名带后缀]
有UIhttps://[自定义域名](/dav/)[文件名带后缀]
/dav不一定要写

示例体验

速度慢一点无所谓了,下面放几首歌,可以体验

有UI

反乌托邦Pt.2 38.8 MB

DAMIDAMI 39.3 MB

提瓦特民谣 129 MB

无UI

{% note simple %}
让风告诉你 141.1MB

{% endnote %}

{% note info solid %}
我不曾忘记 148 MB

{% endnote %}

{% note success solid %}
你从未离去 68.1MB

{% endnote %}

文章作者: 余漾

文章链接: https://blog.yvyang.top/posts/20242/index.HTML

版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 YvYang's Blog

评论加载中…