个人 WebDAV 接入Vooh手册

本文只面向个人使用:把自己可访问的 WebDAV 音乐目录接入 Vooh,在自己的设备上浏览、搜索和在线播放。示例使用一个目录中的音频文件,重点是讲清插件协议。请将示例中的地址和凭据换成自己的私有服务配置。

1. 运行方式与准备

Vooh 从用户添加的 HTTP(S) 地址下载一个 JSON 订阅。订阅内同时包含插件声明(manifest)和完整 JavaScript 代码。App 校验整个订阅后才启用,并在本机缓存。插件调用宿主提供的 voohHttp 请求 WebDAV;播放时插件返回音频地址和请求头,宿主负责媒体探测及播放。

准备一个仅供本人访问的 WebDAV 目录,例如 https://dav.example.net/dav/music/。示例假设:

  • 目录支持 PROPFIND 和 Depth: 1,返回标准 207 Multi-Status XML。

  • 音频文件支持带鉴权头的 GET 和 Range 请求,且鉴权不会把请求重定向到另一个源站。

  • 音频文件直接放在该目录;示例不递归读取子目录。

  • 文件名包含可展示的曲名;示例不从文件标签中提取歌手或专辑。

建议先用自己的 WebDAV 客户端确认这些条件。插件 HTTP 桥接目前允许 GET、POST、PUT、PATCH、DELETE、HEAD 和 PROPFIND。voohHttp 可发送文本或字节请求体,默认把响应作为文本返回;它不会把非 2xx 状态自动变成异常,因此插件需要检查 status。

阅读路线:第 2、3、5 节可以完成最小插件;第 4 节集中列出当前插件协议的字段、所有能力方法及宿主桥接接口,不必再查阅其它协议文档。

2. 编写 plugin.js

以下是一个完整的个人使用示例。把 ORIGIN、ROOT_PATH 和 AUTHORIZATION 改成自己的值。AUTHORIZATION 里的 Basic 字符串是“用户名:密码”的 Base64 编码,并非加密;也可以按个人服务的要求改成 Bearer <token>。凭据始终固化在插件代码中,不应放入 Candidate、日志、错误消息或订阅 URL。

js
// 仅供个人使用:这些常量会原样进入订阅 JSON。
const ORIGIN = "https://dav.example.net";
const ROOT_PATH = "/dav/music/"; // 必须以 / 开头并以 / 结尾
const AUTHORIZATION = "Basic REPLACE_WITH_YOUR_BASE64_CREDENTIALS";
const ADAPTER_ID = "personal_webdav";
const PAGE_SIZE = 50;
const AUDIO_EXT = /\.(mp3|m4a|aac|flac|wav|ogg)$/i;

function xmlText(value) {
  return value.replace(/&#(x[0-9a-f]+|[0-9]+);|&(amp|lt|gt|quot|apos);/gi,
    (_, number, named) => {
      if (number) {
        const code = number[0].toLowerCase() === "x"
          ? parseInt(number.slice(1), 16) : parseInt(number, 10);
        return Number.isFinite(code) && code <= 0x10ffff
          ? String.fromCodePoint(code) : "";
      }
      return {amp: "&", lt: "<", gt: ">", quot: '"', apos: "'"}[named.toLowerCase()];
    });
}

function directAudioPath(href) {
  // DAV:href 通常是绝对路径,也可能是同一源站的完整 URL。
  let path = xmlText(href.trim());
  if (path.startsWith(ORIGIN + "/")) path = path.slice(ORIGIN.length);
  if (!path.startsWith(ROOT_PATH) || path.includes("?") || path.includes("#")) return null;
  const tail = path.slice(ROOT_PATH.length);
  if (!tail || tail.includes("/") || path.length > 256) return null;
  let filename;
  try { filename = decodeURIComponent(tail); } catch (_) { return null; }
  return AUDIO_EXT.test(filename) ? {path, filename} : null;
}

function parseListing(xml) {
  // 这里只读取 DAV:response 中的 href 和 collection 标记;不依赖 XML 前缀名称。
  const blocks = xml.match(/<(?:[\w.-]+:)?response\b[^>]*>[\s\S]*?<\/(?:[\w.-]+:)?response\s*>/gi) || [];
  const result = [];
  const seen = new Set();
  for (const block of blocks) {
    if (/<(?:[\w.-]+:)?collection\b/i.test(block)) continue;
    const href = block.match(/<(?:[\w.-]+:)?href\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?href\s*>/i);
    if (!href) continue;
    const file = directAudioPath(href[1]);
    if (file && !seen.has(file.path)) {
      seen.add(file.path);
      result.push(file);
    }
  }
  return result.sort((a, b) => a.filename.localeCompare(b.filename));
}

async function listAudioFiles() {
  const response = await voohHttp({
    url: ORIGIN + ROOT_PATH,
    method: "PROPFIND",
    headers: {
      Authorization: AUTHORIZATION,
      Depth: "1",
      "Content-Type": "application/xml; charset=utf-8"
    },
    body: '<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
  });
  if (response.status === 401 || response.status === 403) {
    const error = new Error("个人 WebDAV 目录鉴权失败");
    error.code = "authentication";
    throw error;
  }
  if (response.status !== 207) {
    throw new Error("个人 WebDAV 目录读取失败,HTTP " + response.status);
  }
  return parseListing(response.text);
}

function toCandidate(file) {
  const title = file.filename.replace(AUDIO_EXT, "");
  return {
    source: {adapterId: ADAPTER_ID, sourceId: file.path},
    track: {id: "", title, artists: [], album: ""}
  };
}

function page(files, offset) {
  const items = files.slice(offset, offset + PAGE_SIZE).map(toCandidate);
  const next = offset + PAGE_SIZE;
  return {items, ...(next < files.length ? {nextCursor: String(next)} : {})};
}

globalThis.voohPlugin = {
  async search({text, cursor}) {
    const files = await listAudioFiles();
    const query = String(text || "").trim().toLocaleLowerCase();
    const matches = files.filter(file => file.filename.toLocaleLowerCase().includes(query));
    const offset = /^(0|[1-9][0-9]*)$/.test(String(cursor ?? "0")) ? Number(cursor ?? 0) : 0;
    return page(matches, offset);
  },

  async sections() {
    return [{id: "music", title: "我的 WebDAV 音乐"}];
  },

  async collection({id}) {
    if (id !== "music") return {items: []};
    // 当前发现页调用 collection 时不传分页 cursor,故仅展示前 50 首。
    return page(await listAudioFiles(), 0);
  },

  async resolve({source, candidate}) {
    if (source?.adapterId !== ADAPTER_ID || typeof source.sourceId !== "string") {
      throw new Error("歌曲来源无效");
    }
    const file = directAudioPath(source.sourceId);
    if (!file || file.path !== source.sourceId) throw new Error("文件路径无效");
    const selected = candidate && candidate.source?.adapterId === ADAPTER_ID &&
      candidate.source?.sourceId === source.sourceId ? candidate : toCandidate(file);
    return {
      candidate: selected,
      url: ORIGIN + file.path,
      protocol: "http",
      headers: {Authorization: AUTHORIZATION}
    };
  }
};复制

此示例仅为个人目录的最小实现。它用轻量文本提取读取标准 DAV 列表;若个人服务返回复杂 XML(例如特殊命名空间、CDATA 或非标准 href),应替换为适合该服务的完整 XML 解析逻辑。voohHttp 的单次响应上限为 8 MiB,因此非常大的目录也需要拆分成多个个人目录或改为按需索引。

3. 声明 manifest 并生成订阅

为这个例子创建 manifest.json:

json
{
  "formatVersion": 1,
  "pluginId": "personal.webdav.music",
  "version": "1.0.0",
  "hostApiRange": {"min": 1, "max": 1},
  "engineTarget": "quickjs-2026-06-04-source",
  "sourcePlatforms": ["personal_webdav"],
  "capabilities": ["discover", "collection", "stream"],
  "entrypoints": {"main": "plugin.js"},
  "permissions": {
    "httpDomains": ["dav.example.net"],
    "mediaDomains": ["dav.example.net"]
  }
}复制

字段含义:

字段含义与本例取值formatVersion订阅和 manifest 当前均为 1。pluginId插件实现的唯一 ID;可使用个人命名空间,更新时保持稳定。version主.次.修订 三段数字;更新插件时递增。hostApiRange可运行的宿主 API 版本范围;当前例子为 1。engineTarget当前 JS 引擎目标,必须精确填写示例字符串。sourcePlatforms资源来源 ID;必须与 Candidate 的 source.adapterId 一致。capabilities声明要用的能力;每项都必须导出相应方法。entrypoints当前入口固定为 {&quot;main&quot;:&quot;plugin.js&quot;};代码实际嵌入订阅的 code 字段。permissions描述 HTTP 和媒体目标域名,供宿主识别和诊断;不是凭据或代码的保密边界。

pluginId 须以小写字母开头,只能使用小写字母、数字、点、下划线和连字符,总长 2–64;sourcePlatforms 中每个 ID 须以小写字母开头,只能使用小写字母、数字和下划线,总长 2–32。hostApiRange 只含 min、max,且必须覆盖当前 API 版本 1。permissions 可省略;若填写,httpDomains 至少一个域名,mediaDomains 可为空数组。两者列出的域名只用于描述,不阻止插件实际访问其它 HTTP(S) 地址。streamRoutes 必须与 stream 一同声明;metadataProvider 能力必须同时提供下文所述的 manifest 声明。

订阅顶层只包含 formatVersion、name、plugins;每个插件条目包含 manifest 和 code。为了避免手工转义整段 JS,可在个人电脑上用 Python 标准库打包:

python
import json
from pathlib import Path

manifest = json.loads(Path("manifest.json").read_text(encoding="utf-8"))
code = Path("plugin.js").read_text(encoding="utf-8")
bundle = {
    "formatVersion": 1,
    "name": "我的个人 WebDAV 音乐",
    "plugins": [{"manifest": manifest, "code": code}],
}
Path("bundle.json").write_text(
    json.dumps(bundle, ensure_ascii=False, separators=(",", ":")),
    encoding="utf-8",
)复制

每份订阅可含 1–16 个插件;单个 JS 代码最多 2 MiB,全部代码最多 8 MiB,整个订阅最多 12 MiB。插件 ID 不得重复。订阅中的插件顺序,以及用户添加的订阅顺序,会影响同一来源有多个实现时的优先级。宿主在添加或刷新时会整体校验;刷新失败仍保留上一次有效缓存。

订阅 name 须为非空文本,最长 100 字符;单个 App 最多保存 16 个订阅。订阅 URL 必须是 HTTP(S),不得包含用户名密码或片段标识。添加时会下载并校验整份订阅和声明的 JS 方法,运行期间调用才会检查实际请求与返回数据。修改代码后需要重新打包、更新私有服务上的 JSON,并在 App 中刷新该订阅。

4. 接口逐项理解

插件入口是 globalThis.voohPlugin 对象。方法可以是 async,输入和输出都必须能被 JSON 序列化。声明的能力必须有对应方法;特别是 discover 要同时实现 search 和 sections,stream 对应 resolve。除 sections 可以直接返回数组、lyrics 可以直接返回字符串或 null 外,方法通常返回对象。

Candidate 与来源

Candidate 描述一首可被宿主识别的歌曲来源。最小结构如下:

json
{
  "source": {"adapterId": "personal_webdav", "sourceId": "/dav/music/Example.mp3"},
  "track": {"id": "", "title": "Example", "artists": [], "album": ""}
}复制

sourceId 应稳定标识同一个文件,不要放凭据;本例使用 URL 的已编码路径。track.title 和 track.artists 必填,album 可为空。可补充 durationMs、isrc、version、artwork、uploader、sourcePage、tags 等资料。Candidate 可能随歌曲来源保存;不要把播放 URL、鉴权头或临时有效期塞进它。更改文件路径会改变本例的来源标识。

source.adapterId 与 source.sourceId 均须为非空字符串,后者最长 256 字符。track.title 最长 512 字符;artists 最多 32 项,每项最长 256 字符;album 最长 512 字符,durationMs 如填写须为非负整数。track.id 在插件候选中可填空字符串,歌曲的本地 ID 由宿主管理。返回的 Candidate 来源平台必须属于 manifest 的 sourcePlatforms。

发现、搜索与目录浏览

能力方法输入 → 输出本例用途discoversearch{text,cursor?} → {items:Candidate[],nextCursor?}按文件名过滤个人目录;每页最多 50 首。discoversections{} → [{id,title,subtitle?}]在发现页提供一个个人目录入口;无栏目时返回 []。collectioncollection{id,cursor?} → {items:Candidate[],nextCursor?}打开对应栏目,列出个人目录里的歌曲。

搜索和合集结果每次最多返回 100 个项目。search 的 nextCursor 可供宿主继续请求;当前发现页对 collection 的调用只传 id,本例因此只展示排序后的前 50 首。空目录返回空 items,非音频文件、子目录和目录自身均被跳过。

播放

stream 的 resolve 接收 {source,candidate?,quality,routeId?},返回至少含 candidate 和 url 的对象。本例还返回 protocol: &quot;http&quot; 和 headers.Authorization。返回的 Candidate 的 SourceRef 必须与请求中的 source 完全一致;宿主还会检查曲目版本是否与用户选择的歌曲相符。

可选播放字段包括 mimeType、contentLength、expiresAt(绝对 Unix 毫秒)、lyrics 和 quality。仅在确知准确值时填写。HTTP 音频会先以 Range 请求探测,宿主根据文件签名识别音频格式;仅给出音频 Content-Type 不足以通过探测。实际播放也会使用 resolve 返回的请求头。鉴权失效时应更新个人插件代码中的凭据、重新生成订阅并在 App 中刷新。

url 必须是没有嵌入用户名密码的 HTTP(S) 地址;protocol 可为 http、hls 或 dash,省略时为 http。quality 可为 128k、192k、320k、flac、flac24bit。headers 是字符串键值对象,不可覆盖 Host、Content-Length、Connection。expiresAt 必须晚于当前时间;contentLength 为非负整数,若与实际探测长度不符会被拒绝。preview: true 的试听结果、过期地址、与请求不一致的 Candidate、非音频响应或不匹配的曲目版本也会被拒绝。分片协议由宿主代理清单与片段;本例的 WebDAV 文件使用普通 HTTP 音频。

其他有用的能力

能力方法个人使用时可做什么metadatametadata({candidate}) → Candidate根据个人文件旁的资料补充标题、专辑、封面等;必须保持来源不变。lyricslyrics({source}) → {lyrics:string|null}读取个人目录中的歌词文本;没有歌词可返回 null。streamRoutesstreamRoutes({source,candidate,quality}) → {routes:[{id,qualities}]}有多条个人访问路径时声明路由;同时必须声明 stream,由宿主逐条探测和切换。metadataProviderproviderSearch、providerDetails独立提供资料候选;manifest 还须有 metadataProvider: {id,fields,minIntervalMs}。普通 discover 搜索也会作为来源资料候选。playlistImportplaylistImport({text}) → 预览对象将个人使用的歌单输入解析成导入预览;接口与模拟示例见下一节。

metadata({candidate}) 返回补全后的完整 Candidate,来源必须不变。lyrics({source}) 返回 {lyrics:&quot;[00:01.00]...&quot;} 或 {lyrics:null};只声明 lyrics 而没有文字时也应正常返回空值。歌词可在播放开始后由宿主异步补全,不阻塞音频启动。两种补全中的任一请求失败,宿主可保留已有资料并记录提示。

streamRoutes({source,candidate,quality}) 返回 {routes:[{id,qualities}]};每条路线有唯一的非空 id 和至少一个上文列出的 quality。宿主再为选中的路线调用 resolve,这时输入带 routeId。不声明 streamRoutes 的 stream 插件会使用隐含的 default 路线。多路线应代表同一首已选歌曲的不同个人访问路径,由宿主按路线分别进行超时、探测、退避和失败切换;插件不要在一次 resolve 中自行循环所有路线。

独立的 metadataProvider 要在 manifest 中同时加入能力名与声明,例如:

json
"metadataProvider": {
  "id": "personal_metadata",
  "fields": ["title", "artists", "album", "artwork"],
  "minIntervalMs": 500
}复制

id 是提供者 ID,在同一订阅内不能重复;fields 为它能补充的字段名,至少一项、最多 32 项;minIntervalMs 为宿主保证的最小调用间隔(0–600000 毫秒)。还须导出 providerSearch({text,track?,cursor?}) 与 providerDetails({candidate}):

json
{
  "items": [{
    "candidate": {
      "source": {"adapterId": "personal_webdav", "sourceId": "/dav/music/Example.mp3"},
      "track": {"id": "", "title": "Example", "artists": [], "album": ""}
    },
    "fields": ["title", "album"],
    "releaseId": "personal-record-1",
    "warnings": []
  }],
  "nextCursor": "2"
}复制

这是 providerSearch 的返回形状,每次最多 100 项;nextCursor 无下一页时可省略。providerDetails 的输入是 {candidate: 上述 items 中的一项},直接返回补全后的同形状单项,来源须与输入一致。fields 标明该项实际可提供的字段;releaseId、warnings 可省略。普通来源插件只要声明 discover 并提供 search,宿主也会将其搜索结果作为该来源的资料候选;需要独立资料库时再用 metadataProvider。

宿主 HTTP、工具与状态接口

voohHttp({url,method?,headers?,body?,responseType?}) 是插件使用的网络入口。url 必须为 HTTP(S);method 省略时为 GET,允许的方法已在第 1 节列出;headers 是字符串键值对象;body 可为字符串(UTF-8 编码)或 0–255 的字节数组;responseType 为 text(默认)或 bytes。示例:

js
const result = await voohHttp({
  url: ORIGIN + ROOT_PATH,
  method: "PROPFIND",
  headers: {Authorization: AUTHORIZATION, Depth: "1"},
  body: "<propfind/>",
  responseType: "text"
});
// result: {status: 207, url: "最终请求地址", headers: {...}, text: "..."}
// responseType: "bytes" 时改为 bodyBase64: "...",没有 text。复制

单次请求体上限 1 MiB,响应体上限 8 MiB;最多跟随四次重定向,跨源站重定向会移除 Authorization、Cookie、Referer 和 Origin。HTTP 状态码始终放在 status,插件应自行处理 401、403、404 等响应;网络或参数错误会抛出带 code、message 的异常。域名声明不是网络访问控制。插件方法的 JSON 输入最多 256 KiB;返回输出通常最多 1 MiB,playlistImport 最多 8 MiB。宿主会限制执行时间与内存,不能把全库音频内容读入 JS。

其它可直接调用的全局函数:

函数输入 → 输出voohCodec(&quot;base64Encode&quot;, byteArray) / voohCodec(&quot;base64Decode&quot;, text)Base64 编码为字符串 / 解码为字节数组。voohCodec(&quot;hexEncode&quot;, byteArray) / voohCodec(&quot;hexDecode&quot;, text)十六进制编码为字符串 / 解码为字节数组。voohCodec(&quot;utf8Decode&quot;, byteArray)UTF-8 字节数组解码为字符串。voohDigest(&quot;sha256&quot;, byteArray) / voohDigest(&quot;md5&quot;, byteArray)计算摘要,返回字节数组;可用 hexEncode 转成字符串。await voohSleep(milliseconds)异步等待,单次最长 30000 毫秒,仍受宿主调用时限约束。voohRandom(length)返回安全随机字节数组,最多 4096 字节。voohStoreGet(key) / voohStoreSet(key, stringValue) / voohStoreRemove(key)读取、写入或删除当前插件的少量持久状态。

存储键只允许字母、数字、点、下划线、连字符,长度最多 128;值必须为字符串,UTF-8 编码后最多 4096 字节。每个插件最多 256 个键、总量最多 128 KiB。不要把个人 WebDAV 凭据复制到存储中。插件调用可能在新的 JS 运行环境中执行,不能依赖全局变量跨调用保存目录列表;需要跨调用保存的少量非敏感状态才使用存储。插件可通过 throw Object.assign(new Error(&quot;说明&quot;), {code:&quot;authentication&quot;}) 报错;错误消息不要包含凭据,网络、超时和格式错误由宿主分别处理。

可选:为个人歌单增加导入解析能力

上面的完整 WebDAV 示例没有实现歌单导入。只有想在自己的设备上解析歌单输入时,才需要在 manifest 的 capabilities 中额外加入 &quot;playlistImport&quot;,并在同一个 globalThis.voohPlugin 对象上导出 async playlistImport({text}) 方法。App 只根据已成功加载插件的能力声明决定是否显示“导入歌单”入口;打开页面后,粘贴的文本才会传给该方法。

下面以本人整理的本地 JSON 歌单作接口形状演示。宿主原生支持从文件导入以下格式;如果想让插件处理粘贴到文本框里的同类 JSON,则由 playlistImport({text}) 解析并映射到个人 WebDAV 文件:

json
{
  "formatVersion": 1,
  "name": "我的个人歌单",
  "tracks": [
    {"title": "Example", "artists": [], "album": ""},
    {"title": "Another", "artists": [], "album": ""}
  ]
}复制
  1. 方法收到 {text},先判断它是否为自己支持的 JSON 歌单格式。不属于时返回 {&quot;matched&quot;:false},宿主会按订阅顺序尝试下一个声明了 playlistImport 的插件。

  2. 属于时,插件读取 name 和 tracks,再将曲目逐一对应到本人 WebDAV 目录中真实存在的文件。这个对应过程可依据本人维护的文件名或索引设计;找不到个人文件的曲目不应伪造 WebDAV 来源,可计入 issues。

  3. 对应成功的文件使用前文 toCandidate 所述的 Candidate 结构;source.adapterId 必须在该插件的 sourcePlatforms 内,source.sourceId 必须能由同一插件的 resolve 找到并播放。

  4. 返回预览对象;用户查看预览并确认后,宿主才会把这些曲目导入个人歌单。

例如,把示例 manifest 的能力数组扩展为 &quot;capabilities&quot;: [&quot;discover&quot;, &quot;collection&quot;, &quot;stream&quot;, &quot;playlistImport&quot;],保留 &quot;sourcePlatforms&quot;: [&quot;personal_webdav&quot;]。一次模拟匹配的返回值可以是:

json
{
  "matched": true,
  "name": "我的个人歌单",
  "items": [
    {
      "source": {"adapterId": "personal_webdav", "sourceId": "/dav/music/Example.mp3"},
      "track": {"id": "", "title": "Example", "artists": [], "album": ""}
    }
  ],
  "expectedCount": 2,
  "complete": false,
  "issues": ["第 2 首在个人 WebDAV 目录中没有对应文件,已跳过"]
}复制

matched 必须是布尔值。匹配时,name 为非空歌单名,items 为 Candidate 数组,complete 为布尔值;expectedCount 可以省略或填原始曲目数,issues 可以省略或填问题说明数组。complete: false 适用于示例中只找到部分个人文件的情况。宿主会校验这些字段和来源平台;items 最多 10,000 项,单次插件输出最多 8 MiB。页面的“导入 JSON”按钮由宿主直接解析本地文件,不调用 playlistImport。本人也可以手动整理其它网页中的个人列表内容,转换成上述合法 JSON 格式后再导入;这里不涉及网页解析实现。

5. 私有部署并在 App 中使用

  1. 在本人的电脑上生成 bundle.json,核对其中 code 已包含正确的个人 WebDAV 地址和凭据;不要把生成文件或原始 JS 放到公开仓库。

  2. 将 bundle.json 放在仅本人设备可访问的 HTTP(S) 服务上,例如只在个人专用网络内可达的地址。订阅下载使用普通 GET;当前订阅地址不支持在 App 中另外配置鉴权请求头,也不接受 URL 用户信息。因此部署方式必须让本人设备能够直接读取该文件,同时阻止其他人读取。

  3. 在 App 的搜索页输入该 bundle.json 的完整 URL 并提交,以添加插件订阅。App 会下载、校验并缓存订阅;启用后在发现页打开“我的 WebDAV 音乐”,或按文件名搜索,再点选歌曲在线播放。已添加的订阅可在设置中的插件来源页面刷新、停用或移除。

  4. 修改目录地址或凭据后,重新打包并在 App 中刷新订阅。若校验或下载失败,查看订阅错误并修正;旧的有效缓存仍会保留。

保密边界: JSON 中的 JS 和固化凭据是可读文本,Basic 的 Base64 字符串也能还原。私有服务的访问控制、受限网络和对设备的保护,是防止他人取得订阅代码的必要条件。避免在构建日志、分享链接、故障截图中泄露订阅内容或凭据。私有部署无法保证拥有设备或订阅读取权限的人拿不到代码;若必须让别人取得订阅,就不能把其中的凭据视为秘密。

6. 排查顺序

现象优先检查添加订阅失败订阅 URL 是否可由该设备直接 GET、JSON 是否有效、manifest 字段及能力方法是否对应。目录读取失败WebDAV 根路径末尾的 /、PROPFIND 是否返回 207、个人服务的 Depth: 1 行为及响应大小。鉴权失败固化的 Authorization 值是否正确、服务是否将请求重定向到另一源站;跨源站重定向会移除鉴权头。目录为空音频是否位于根目录的直接子级、扩展名是否在示例列表中、DAV href 是否采用可识别的形式。能看到歌曲但无法播放文件 GET/Range 是否可用、返回的是否是真实音频、resolve 的 Candidate 来源是否与请求一致。

以上步骤均以个人 WebDAV 音乐库和个人设备为前提。需要更复杂的目录层级时,可以在理解基础接口后,分别设计目录索引与分页,而不必改变订阅和 Candidate 的基本形状。