个人 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-StatusXML。音频文件支持带鉴权头的
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。
// 仅供个人使用:这些常量会原样进入订阅 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:
{
"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当前入口固定为 {"main":"plugin.js"};代码实际嵌入订阅的 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 标准库打包:
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 描述一首可被宿主识别的歌曲来源。最小结构如下:
{
"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: "http" 和 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:"[00:01.00]..."} 或 {lyrics:null};只声明 lyrics 而没有文字时也应正常返回空值。歌词可在播放开始后由宿主异步补全,不阻塞音频启动。两种补全中的任一请求失败,宿主可保留已有资料并记录提示。
streamRoutes({source,candidate,quality}) 返回 {routes:[{id,qualities}]};每条路线有唯一的非空 id 和至少一个上文列出的 quality。宿主再为选中的路线调用 resolve,这时输入带 routeId。不声明 streamRoutes 的 stream 插件会使用隐含的 default 路线。多路线应代表同一首已选歌曲的不同个人访问路径,由宿主按路线分别进行超时、探测、退避和失败切换;插件不要在一次 resolve 中自行循环所有路线。
独立的 metadataProvider 要在 manifest 中同时加入能力名与声明,例如:
"metadataProvider": {
"id": "personal_metadata",
"fields": ["title", "artists", "album", "artwork"],
"minIntervalMs": 500
}复制id 是提供者 ID,在同一订阅内不能重复;fields 为它能补充的字段名,至少一项、最多 32 项;minIntervalMs 为宿主保证的最小调用间隔(0–600000 毫秒)。还须导出 providerSearch({text,track?,cursor?}) 与 providerDetails({candidate}):
{
"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。示例:
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("base64Encode", byteArray) / voohCodec("base64Decode", text)Base64 编码为字符串 / 解码为字节数组。voohCodec("hexEncode", byteArray) / voohCodec("hexDecode", text)十六进制编码为字符串 / 解码为字节数组。voohCodec("utf8Decode", byteArray)UTF-8 字节数组解码为字符串。voohDigest("sha256", byteArray) / voohDigest("md5", 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("说明"), {code:"authentication"}) 报错;错误消息不要包含凭据,网络、超时和格式错误由宿主分别处理。
可选:为个人歌单增加导入解析能力
上面的完整 WebDAV 示例没有实现歌单导入。只有想在自己的设备上解析歌单输入时,才需要在 manifest 的 capabilities 中额外加入 "playlistImport",并在同一个 globalThis.voohPlugin 对象上导出 async playlistImport({text}) 方法。App 只根据已成功加载插件的能力声明决定是否显示“导入歌单”入口;打开页面后,粘贴的文本才会传给该方法。
下面以本人整理的本地 JSON 歌单作接口形状演示。宿主原生支持从文件导入以下格式;如果想让插件处理粘贴到文本框里的同类 JSON,则由 playlistImport({text}) 解析并映射到个人 WebDAV 文件:
{
"formatVersion": 1,
"name": "我的个人歌单",
"tracks": [
{"title": "Example", "artists": [], "album": ""},
{"title": "Another", "artists": [], "album": ""}
]
}复制方法收到
{text},先判断它是否为自己支持的 JSON 歌单格式。不属于时返回{"matched":false},宿主会按订阅顺序尝试下一个声明了playlistImport的插件。属于时,插件读取
name和tracks,再将曲目逐一对应到本人 WebDAV 目录中真实存在的文件。这个对应过程可依据本人维护的文件名或索引设计;找不到个人文件的曲目不应伪造 WebDAV 来源,可计入issues。对应成功的文件使用前文
toCandidate所述的 Candidate 结构;source.adapterId必须在该插件的sourcePlatforms内,source.sourceId必须能由同一插件的resolve找到并播放。返回预览对象;用户查看预览并确认后,宿主才会把这些曲目导入个人歌单。
例如,把示例 manifest 的能力数组扩展为 "capabilities": ["discover", "collection", "stream", "playlistImport"],保留 "sourcePlatforms": ["personal_webdav"]。一次模拟匹配的返回值可以是:
{
"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 中使用
在本人的电脑上生成
bundle.json,核对其中code已包含正确的个人 WebDAV 地址和凭据;不要把生成文件或原始 JS 放到公开仓库。将
bundle.json放在仅本人设备可访问的 HTTP(S) 服务上,例如只在个人专用网络内可达的地址。订阅下载使用普通 GET;当前订阅地址不支持在 App 中另外配置鉴权请求头,也不接受 URL 用户信息。因此部署方式必须让本人设备能够直接读取该文件,同时阻止其他人读取。在 App 的搜索页输入该
bundle.json的完整 URL 并提交,以添加插件订阅。App 会下载、校验并缓存订阅;启用后在发现页打开“我的 WebDAV 音乐”,或按文件名搜索,再点选歌曲在线播放。已添加的订阅可在设置中的插件来源页面刷新、停用或移除。修改目录地址或凭据后,重新打包并在 App 中刷新订阅。若校验或下载失败,查看订阅错误并修正;旧的有效缓存仍会保留。
保密边界: JSON 中的 JS 和固化凭据是可读文本,Basic 的 Base64 字符串也能还原。私有服务的访问控制、受限网络和对设备的保护,是防止他人取得订阅代码的必要条件。避免在构建日志、分享链接、故障截图中泄露订阅内容或凭据。私有部署无法保证拥有设备或订阅读取权限的人拿不到代码;若必须让别人取得订阅,就不能把其中的凭据视为秘密。
6. 排查顺序
现象优先检查添加订阅失败订阅 URL 是否可由该设备直接 GET、JSON 是否有效、manifest 字段及能力方法是否对应。目录读取失败WebDAV 根路径末尾的 /、PROPFIND 是否返回 207、个人服务的 Depth: 1 行为及响应大小。鉴权失败固化的 Authorization 值是否正确、服务是否将请求重定向到另一源站;跨源站重定向会移除鉴权头。目录为空音频是否位于根目录的直接子级、扩展名是否在示例列表中、DAV href 是否采用可识别的形式。能看到歌曲但无法播放文件 GET/Range 是否可用、返回的是否是真实音频、resolve 的 Candidate 来源是否与请求一致。
以上步骤均以个人 WebDAV 音乐库和个人设备为前提。需要更复杂的目录层级时,可以在理解基础接口后,分别设计目录索引与分页,而不必改变订阅和 Candidate 的基本形状。