From 3accee22d56c7776c91b86906857c718fe34ce9d Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Sun, 2 Aug 2026 03:55:34 +0000 Subject: [PATCH 01/39] feat: add OpenList storage, Alidrive integration, card-based registration & forced login - OpenList: multi-server CRUD config (openlist.json), browse/search/stream/lyric/upload via AList-compatible API, server-side proxy streaming with Range support - Alidrive: client config, QR login binding, file browse/play/upload/download - Cards: card-code registration for player accounts - Player: forced login, register page, OpenList & Alidrive manager modules - Admin: OpenList server management, Alidrive binding UI --- .monkeycode/MEMORY.md | 44 ++ public/app.js | 353 +++++++++++ public/index.html | 221 +++++++ public/js/config.js | 2 +- public/music/app.js | 140 ++++- public/music/index.html | 109 ++++ public/music/js/aliyun_manager.js | 402 ++++++++++++ public/music/js/openlist_manager.js | 429 +++++++++++++ public/music/login.html | 225 +++++-- src/defaultConfig.ts | 3 + src/server/alidrive.ts | 452 +++++++++++++ src/server/cards.ts | 116 ++++ src/server/openlist.ts | 369 +++++++++++ src/server/server.ts | 944 +++++++++++++++++++++++++++- src/types/config.d.ts | 15 + 15 files changed, 3773 insertions(+), 51 deletions(-) create mode 100644 .monkeycode/MEMORY.md create mode 100644 public/music/js/aliyun_manager.js create mode 100644 public/music/js/openlist_manager.js create mode 100644 src/server/alidrive.ts create mode 100644 src/server/cards.ts create mode 100644 src/server/openlist.ts diff --git a/.monkeycode/MEMORY.md b/.monkeycode/MEMORY.md new file mode 100644 index 00000000..46547fef --- /dev/null +++ b/.monkeycode/MEMORY.md @@ -0,0 +1,44 @@ +# User Instruction Memory + +This file records user instructions, preferences, and teachings for reference in future interactions. + +## Format + +### User Instruction Entry +User instruction entries should follow this format: + +[User Instruction Summary] +- Date: [YYYY-MM-DD] +- Context: [Mentioned scenario or time] +- Instructions: + - [Content of user teaching or instruction, described line by line] + +### Project Knowledge Entry +Entries discovered by the Agent during task execution should follow this format: + +[Project Knowledge Summary] +- Date: [YYYY-MM-DD] +- Context: Discovered by Agent while performing [specific task description] +- Category: [Operations & Deployment|Build Methods|Testing Methods|Troubleshooting & Debugging|Workflow & Collaboration|Environment Configuration] +- Instructions: + - [Specific knowledge points, described line by line] + +## Deduplication Strategy +- Before adding a new entry, check for similar or identical instructions. +- If a duplicate is found, skip the new entry or merge it with the existing one. +- When merging, update the context or date information. +- This helps avoid redundant entries and keeps the memory file tidy. + +## Entries + +[Project Knowledge Summary] +- Date: 2026-08-01 +- Context: Discovered by Agent while building and deploying lxserver music sync server +- Category: Operations & Deployment +- Instructions: + - Build: `npm run build`(prebuild 会自动下载 fpcalc 二进制并更新 build hash 到 config.js) + - Start: `npm start`,服务器监听 `0.0.0.0:9527`;开发时用 background terminal 启动,避免阻塞 + - 管理员后台入口 `/`,前端密码(`frontend.password`)默认 `123456`;用户密码登录播放器 + - 测试账号:admin/password(管理员)、testuser/123456;管理员鉴权头 `X-Frontend-Auth: ` + - 强制登录开启时(player.forceLogin),播放器静态资源未登录会 302 到 `/music/login`;登录接口 `/api/user/login` 同时下发 `lx_player_session` 与 user token cookie + - 卡密与阿里云盘配置分别持久化在 dataPath 下的 `cards.json`、`alidrive.json`,需配置 ClientID/ClientSecret 并在后台扫码绑定后才能使用云盘功能 diff --git a/public/app.js b/public/app.js index 124ad90e..f4aec9ff 100644 --- a/public/app.js +++ b/public/app.js @@ -276,6 +276,8 @@ class App { webdav: 'WebDAV同步', files: '文件管理', snapshots: '快照管理', + cards: '卡密管理', + alidrive: '阿里云盘', about: '关于' }; document.getElementById('page-title').textContent = titles[viewName] || viewName; @@ -311,6 +313,15 @@ class App { case 'snapshots': this.loadSnapshots(); break; + case 'cards': + this.loadCards(); + break; + case 'alidrive': + this.loadAlidrive(); + break; + case 'openlist': + this.loadOpenList(); + break; case 'about': this.loadAbout(); break; @@ -1956,6 +1967,348 @@ class App { document.getElementById('modal').classList.add('hidden'); } + // ========== 卡密管理 ========== + async loadCards() { + try { + const res = await this.request('/api/card/list'); + const cards = res.cards || []; + const tbody = document.getElementById('cards-table-body'); + if (!tbody) return; + + const total = cards.length; + const unused = cards.filter(c => c.status === 'unused').length; + const used = total - unused; + document.getElementById('cards-total-count').textContent = total; + document.getElementById('cards-unused-count').textContent = unused; + document.getElementById('cards-used-count').textContent = used; + + if (!cards.length) { + tbody.innerHTML = '暂无卡密,点击右上角"生成卡密"'; + return; + } + + tbody.innerHTML = cards.map(card => ` + + + ${this.escapeHtml(card.code)} + ${card.status === 'used' + ? '已使用' + : '未使用'} + ${this.escapeHtml(card.boundUser || '-')} + ${this.escapeHtml(card.remark || '-')} + ${card.expireDays ? card.expireDays + ' 天' : '永久'} + ${new Date(card.createdAt).toLocaleString()} + ${card.usedAt ? new Date(card.usedAt).toLocaleString() : '-'} + + `).join(''); + + document.getElementById('cards-select-all').checked = false; + } catch (err) { + showError('加载卡密失败: ' + err.message); + } + } + + toggleSelectAllCards(checked) { + document.querySelectorAll('.card-checkbox').forEach(cb => { + cb.checked = checked; + }); + } + + deleteSelectedCards() { + const ids = Array.from(document.querySelectorAll('.card-checkbox:checked')).map(cb => cb.value); + if (!ids.length) { + showError('请先勾选要删除的卡密'); + return; + } + if (!confirm(`确定删除选中的 ${ids.length} 张卡密吗?`)) return; + this.request('/api/card/delete', { + method: 'POST', + body: JSON.stringify({ ids }) + }).then(() => { + showSuccess('删除成功'); + this.loadCards(); + }).catch(err => showError('删除失败: ' + err.message)); + } + + showGenerateCardsModal() { + const modal = document.getElementById('modal'); + document.getElementById('modal-title').textContent = '生成卡密'; + document.getElementById('modal-body').innerHTML = ` +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ `; + modal.classList.remove('hidden'); + + document.getElementById('confirm-generate-cards').addEventListener('click', () => { + const count = parseInt(document.getElementById('generate-cards-count').value || '1', 10); + const expireDays = document.getElementById('generate-cards-expire').value; + const remark = document.getElementById('generate-cards-remark').value.trim(); + this.request('/api/card/generate', { + method: 'POST', + body: JSON.stringify({ count, expireDays: expireDays ? parseInt(expireDays, 10) : null, remark }) + }).then(res => { + modal.classList.add('hidden'); + showSuccess(`成功生成 ${res.cards.length} 张卡密`); + this.loadCards(); + }).catch(err => showError('生成失败: ' + err.message)); + }); + } + + // ========== 阿里云盘 ========== + async loadAlidrive() { + try { + const res = await this.request('/api/alidrive/config'); + document.getElementById('alidrive-client-id').value = res.clientId || ''; + document.getElementById('alidrive-client-secret').value = res.clientSecret || ''; + this.updateAlidriveStatus(res); + } catch (err) { + showError('加载阿里云盘配置失败: ' + err.message); + } + } + + updateAlidriveStatus(res) { + const statusEl = document.getElementById('alidrive-status'); + if (!statusEl) return; + const linked = res && res.linked; + const userName = (res && res.userName) || ''; + if (linked) { + statusEl.innerHTML = ` + 已绑定 + 账号:${this.escapeHtml(userName || '未知')}`; + document.getElementById('alidrive-unlink-btn').style.display = 'inline-block'; + } else { + const hasClient = !!(document.getElementById('alidrive-client-id').value && document.getElementById('alidrive-client-secret').value); + statusEl.innerHTML = hasClient + ? '尚未绑定,请点击"获取二维码"扫码登录' + : '尚未配置应用凭据,请先填写 ClientID 与 ClientSecret 并保存'; + document.getElementById('alidrive-unlink-btn').style.display = 'none'; + } + } + + async saveAlidriveClient() { + const clientId = document.getElementById('alidrive-client-id').value.trim(); + const clientSecret = document.getElementById('alidrive-client-secret').value.trim(); + if (!clientId || !clientSecret) { + showError('请填写 ClientID 与 ClientSecret'); + return; + } + try { + await this.request('/api/alidrive/config', { + method: 'POST', + body: JSON.stringify({ clientId, clientSecret }) + }); + showSuccess('凭据已保存'); + this.updateAlidriveStatus({ linked: false }); + } catch (err) { + showError('保存失败: ' + err.message); + } + } + + async startAlidriveQrLogin() { + const qrImg = document.getElementById('alidrive-qrcode-img'); + const placeholder = document.getElementById('alidrive-qrcode-placeholder'); + placeholder.style.display = 'flex'; + placeholder.textContent = '正在获取二维码...'; + qrImg.style.display = 'none'; + qrImg.innerHTML = ''; + + let sid = ''; + try { + const res = await this.request('/api/alidrive/qrcode', { method: 'POST' }); + sid = res.sid; + if (!res.qr_content) throw new Error('未获取到二维码内容'); + + placeholder.style.display = 'none'; + qrImg.style.display = 'block'; + qrImg.innerHTML = `扫码登录二维码`; + this.pollAlidriveQr(sid); + } catch (err) { + placeholder.style.display = 'flex'; + placeholder.textContent = '获取二维码失败: ' + err.message; + } + } + + async pollAlidriveQr(sid) { + let qrImg = document.getElementById('alidrive-qrcode-img'); + let placeholder = document.getElementById('alidrive-qrcode-placeholder'); + for (let i = 0; i < 60; i++) { + await new Promise(r => setTimeout(r, 2000)); + try { + const res = await this.request(`/api/alidrive/qrcode/status?sid=${encodeURIComponent(sid)}`); + if (res.status === 'LoginSuccess') { + placeholder.style.display = 'flex'; + placeholder.textContent = '扫码成功,绑定完成!'; + qrImg.style.display = 'none'; + showSuccess('阿里云盘绑定成功'); + this.loadAlidrive(); + return; + } + if (res.status === 'Expired' || res.status === 'Cancel') { + placeholder.style.display = 'flex'; + placeholder.textContent = '二维码已失效,请重新获取'; + qrImg.style.display = 'none'; + return; + } + } catch (err) { + // 继续轮询 + } + } + placeholder.style.display = 'flex'; + placeholder.textContent = '等待扫码超时,请重新获取二维码'; + qrImg.style.display = 'none'; + } + + async unlinkAlidrive() { + if (!confirm('确定解除阿里云盘绑定吗?')) return; + try { + await this.request('/api/alidrive/unlink', { method: 'POST' }); + showSuccess('已解除绑定'); + this.loadAlidrive(); + } catch (err) { + showError('解除绑定失败: ' + err.message); + } + } + + // ========== OpenList ========== + async loadOpenList() { + try { + const res = await this.request('/api/openlist/servers'); + this.renderOpenListServers(res.servers || []); + } catch (err) { + showError('加载 OpenList 服务器失败: ' + err.message); + } + } + + renderOpenListServers(servers) { + const container = document.getElementById('openlist-server-list'); + if (!container) return; + if (!servers.length) { + container.innerHTML = '
尚未添加任何 OpenList 服务器,点击右上角"添加服务器"开始配置。
'; + return; + } + const rows = servers.map(s => ` +
+
+
+ ${this.escapeHtml(s.name)} + ${s.enabled ? '已启用' : '已停用'} + ${s.hasAuth ? '已配置认证' : '公开访问'} +
+
${this.escapeHtml(s.baseUrl)}${s.rootPath && s.rootPath !== '/' ? ' · 根: ' + this.escapeHtml(s.rootPath) : ''}
+
+
+ + + +
+
`).join(''); + container.innerHTML = rows; + } + + openListModalData = null; + + showOpenListModal(server) { + this.openListModalData = server || null; + document.getElementById('openlist-modal-title').textContent = server ? '编辑 OpenList 服务器' : '添加 OpenList 服务器'; + document.getElementById('ol-name').value = server ? server.name : ''; + document.getElementById('ol-base-url').value = server ? server.baseUrl : ''; + document.getElementById('ol-root-path').value = server ? (server.rootPath || '/') : '/'; + document.getElementById('ol-username').value = server ? server.username : ''; + document.getElementById('ol-password').value = ''; + document.getElementById('ol-token').value = server ? server.token : ''; + document.getElementById('openlist-modal').classList.remove('hidden'); + } + + closeOpenListModal() { + document.getElementById('openlist-modal').classList.add('hidden'); + this.openListModalData = null; + } + + async saveOpenListServer() { + const data = { + name: document.getElementById('ol-name').value.trim(), + baseUrl: document.getElementById('ol-base-url').value.trim(), + rootPath: document.getElementById('ol-root-path').value.trim() || '/', + username: document.getElementById('ol-username').value.trim(), + password: document.getElementById('ol-password').value, + token: document.getElementById('ol-token').value.trim(), + enabled: true, + }; + if (!data.baseUrl) { + showError('请填写 OpenList 地址'); + return; + } + try { + if (this.openListModalData && this.openListModalData.id) { + await this.request('/api/openlist/servers', { + method: 'PUT', + body: JSON.stringify({ id: this.openListModalData.id, ...data }) + }); + showSuccess('服务器已更新'); + } else { + await this.request('/api/openlist/servers', { + method: 'POST', + body: JSON.stringify(data) + }); + showSuccess('服务器已添加'); + } + this.closeOpenListModal(); + this.loadOpenList(); + } catch (err) { + showError('保存失败: ' + err.message); + } + } + + editOpenList(id) { + this.request('/api/openlist/servers').then(res => { + const server = (res.servers || []).find(s => s.id === id); + if (server) this.showOpenListModal(server); + }).catch(err => showError('加载失败: ' + err.message)); + } + + async deleteOpenList(id) { + if (!confirm('确定删除该 OpenList 服务器吗?')) return; + try { + await this.request('/api/openlist/servers', { + method: 'DELETE', + body: JSON.stringify({ id }) + }); + showSuccess('已删除'); + this.loadOpenList(); + } catch (err) { + showError('删除失败: ' + err.message); + } + } + + async testOpenList(id) { + try { + const res = await this.request('/api/openlist/test', { + method: 'POST', + body: JSON.stringify({ id }) + }); + if (res.success) showSuccess('测试成功: ' + res.message); + else showError('测试失败: ' + res.message); + } catch (err) { + showError('测试失败: ' + err.message); + } + } + async request(url, options = {}) { const defaultOptions = { headers: { diff --git a/public/index.html b/public/index.html index a69f740b..7081330c 100644 --- a/public/index.html +++ b/public/index.html @@ -139,6 +139,34 @@

LX Sync

+ + + + + + + 卡密管理 + + + + + + + + + + 阿里云盘 + + + + + + + + + OpenList + + @@ -1218,6 +1246,199 @@

同步日志

+ +
+
+
+

卡密管理

+

生成卡密供用户注册账号使用,每个卡密只能使用一次。

+
+
+ + +
+
+ +
+
+
0
+
卡密总数
+
+
+
0
+
未使用
+
+
+
0
+
已使用
+
+
+ +
+ + + + + + + + + + + + + + + + +
卡密状态绑定用户备注有效期生成时间使用时间
暂无卡密,点击右上角"生成卡密"
+
+
+ +
+
+ + +
+
+
+

阿里云盘

+

配置阿里云盘开放平台凭据并扫码登录,支持云端音乐播放、自动下载与歌词识别。

+
+
+ + +
+

1. 应用凭据配置

+

阿里云盘开放平台为申请制,请先填写 对接申请表 + 并通过审核(审核结果将通知到你的阿里云盘客户端)。审核通过后,在阿里云盘客户端中进入"开放平台"即可创建应用,获取 ClientID 与 ClientSecret,授权范围需包含 file:all:read 与 file:all:write。

+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+

2. 扫码登录绑定

+
未绑定
+
+
+
+ 点击"获取二维码"开始绑定 +
+ +
+ + +
+
+
+
+ + +
+

使用说明

+
    +
  • 绑定成功后,播放器侧边栏将出现"阿里云盘"入口,可浏览并播放云端音频。
  • +
  • 在播放器下载歌曲时,可选择"下载到阿里云盘"将音乐上传至云端目录(默认 /music/lxserver)。
  • +
  • 播放云端音频时,若同目录存在同名 .lrc 文件,将自动识别并加载歌词。
  • +
+
+
+ + +
+
+
+

OpenList 存储

+

添加自己的 OpenList 实例作为存储与读取目录,支持云端音乐播放、自动上传与歌词识别。

+
+
+ +
+
+ +
+
正在加载服务器列表...
+
+ + + +
+
diff --git a/public/js/config.js b/public/js/config.js index d30b9241..590bf828 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -2,6 +2,6 @@ // 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts) // 服务端拦截 /js/config.js 请求, 读取此处版本号并合并服务端配置后返回 window.CONFIG = { - buildHash: 'ed6ced2', + buildHash: '36d6ae5', version: 'v2.0.0', }; diff --git a/public/music/app.js b/public/music/app.js index 5cec8a6d..8ee67a09 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -1098,6 +1098,14 @@ function switchTab(tabId) { document.getElementById('page-title').innerText = "本地音乐"; } + if (tabId === 'alidrive') { + document.getElementById('page-title').innerText = "阿里云盘"; + } + + if (tabId === 'openlist') { + document.getElementById('page-title').innerText = "OpenList"; + } + // Collapse Favorites if leaving if (tabId !== 'favorites') { const favList = document.getElementById('favorites-children'); @@ -3639,7 +3647,7 @@ async function fetchSongUrl(song, quality, isRetry = false, isSilent = false) { const cacheKey = `lx_url_${cleanedSong.id}_${quality}`; // 0. 本地文件/带有本地播放 URL 的歌曲:直接播放本地文件,无需走在线 API 解析 - if ((song.isLocal || song.url?.startsWith('/api/music/cache/file/')) && song.url && !isRetry) { + if ((song.isLocal || song.url?.startsWith('/api/music/cache/file/') || song.url?.startsWith('/api/alidrive/stream') || song.url?.startsWith('/api/openlist/stream')) && song.url && !isRetry) { console.log(`[Cache] Direct Local File Hit: ${song.name}`); let localUrl = await applyAutoProxy(song.url, song); return { url: localUrl, sourceType: 'server_cache', quality: song.quality || quality }; @@ -7075,6 +7083,66 @@ async function fetchLyric(song, quality = null) { } } + // ===== 2.5 阿里云盘歌曲:从云盘同目录读取 .lrc 歌词 ===== + if (source === 'alipan' && song.fileId) { + try { + const lyricRes = await fetch(`/api/alidrive/lyric?fileId=${encodeURIComponent(song.fileId)}`, { headers }); + if (lyricRes.ok) { + const lyricData = await lyricRes.json(); + const lrcText = (lyricData && lyricData.lyric) || ''; + if (lrcText) { + currentRawLrc = lrcText; + currentRawTlrc = ''; + currentRawRlrc = ''; + currentRawKlrc = ''; + if (settings.enableLyricCache !== false) { + try { + localStorage.setItem(cacheKey, JSON.stringify({ lrc: lrcText, tlyric: '', rlyric: '', klyric: '' })); + } catch (e) { } + } + initLyricPlayer(); + applyLyricUpdate(); + return; + } + } + } catch (e) { + console.warn('[Lyric] 阿里云盘歌词获取失败:', e); + } + renderLyric([], '暂无歌词'); + return; + } + + // ===== 2.6 OpenList 歌曲:从同目录读取 .lrc 歌词 ===== + if (source === 'openlist' && song.path) { + try { + let lyricUrl = `/api/openlist/lyric?server=${encodeURIComponent(song.serverId || '')}&path=${encodeURIComponent(song.path)}`; + if (song.sign) lyricUrl += `&sign=${encodeURIComponent(song.sign)}`; + const lyricRes = await fetch(lyricUrl, { headers }); + if (lyricRes.ok) { + const lyricData = await lyricRes.json(); + const lrcText = (lyricData && lyricData.lyric) || ''; + if (lrcText) { + currentRawLrc = lrcText; + currentRawTlrc = ''; + currentRawRlrc = ''; + currentRawKlrc = ''; + if (settings.enableLyricCache !== false) { + try { + localStorage.setItem(cacheKey, JSON.stringify({ lrc: lrcText, tlyric: '', rlyric: '', klyric: '' })); + } catch (e) { } + } + initLyricPlayer(); + applyLyricUpdate(); + return; + } + } + } catch (e) { + console.warn('[Lyric] OpenList 歌词获取失败:', e); + } + renderLyric([], '暂无歌词'); + return; + } + // ===== 3. 从网络抓取最新歌词 ===== try { const params = new URLSearchParams({ @@ -11854,6 +11922,76 @@ function showSelect(title, message, options = {}) { }); } +/** + * 通用表单输入弹窗 + */ +function showInputModal({ title, message = '', fields = [], onConfirm = null, onCancel = null }) { + const modal = document.createElement('div'); + modal.className = "fixed inset-0 z-[200] flex items-center justify-center p-4 animate-fade-in"; + const fieldsHtml = fields.map((f, idx) => ` +
+ + +
+ `).join(''); + modal.innerHTML = ` +
+
+
+

${title}

+ +
+
+ ${message ? `

${message}

` : ''} + ${fieldsHtml} +
+
+ + +
+
+ `; + document.body.appendChild(modal); + + const close = (result, values = null) => { + const content = modal.querySelector('.max-w-sm'); + if (content) content.classList.add('scale-95', 'opacity-0'); + modal.classList.add('opacity-0'); + setTimeout(() => { + modal.remove(); + if (result) { + if (onConfirm && values) onConfirm(values); + } else { + if (onCancel) onCancel(); + } + }, 200); + }; + + modal.querySelector('#input-modal-ok').onclick = () => { + const values = {}; + let valid = true; + fields.forEach((f, idx) => { + const el = modal.querySelector(`#input-field-${idx}`); + const val = (el && el.value || '').trim(); + if (f.required && !val) valid = false; + values[f.id || idx] = val; + }); + if (!valid) { + if (typeof showError === 'function') showError('请填写所有必填项'); + return; + } + close(true, values); + }; + modal.querySelector('#input-modal-cancel').onclick = () => close(false); + modal.querySelector('#input-modal-close-x').onclick = () => close(false); + modal.querySelector('div:first-child').onclick = () => close(false); +} +window.showInputModal = showInputModal; + + /** * 通用多选选择列表 */ diff --git a/public/music/index.html b/public/music/index.html index e1c89912..bcf6fb73 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -120,6 +120,20 @@

LX MUSIC

本地音乐 +
  • + + + 阿里云盘 + +
  • +
  • + + + OpenList + +
  • @@ -1357,6 +1371,99 @@

    本地

  • + + + + + +

    LX Music Web

    -

    输入密码以访问播放器

    +

    登录账号以访问播放器

    - -
    + +
    + + +
    + + + +
    +
    + +
    + +
    -
    @@ -72,97 +92,226 @@

    LX Music Web

    +
    + + +
    -

    请联系管理员获取访问密码

    +
    - \ No newline at end of file + diff --git a/src/defaultConfig.ts b/src/defaultConfig.ts index 8d2dbdef..1b9e2635 100644 --- a/src/defaultConfig.ts +++ b/src/defaultConfig.ts @@ -44,6 +44,9 @@ const config: LX.Config = { // Web播放器配置 'player.enableAuth': false, 'player.password': '123456', + 'player.forceLogin': true, // 是否强制登录(未注册/未登录用户无法进入播放器) + 'player.enableRegister': true, // 是否开放注册(允许使用卡密注册新账号) + 'player.enableAlidrive': true, // 是否启用阿里云盘功能(文件浏览/播放/上传下载) // 代理配置 'proxy.all.enabled': false, diff --git a/src/server/alidrive.ts b/src/server/alidrive.ts new file mode 100644 index 00000000..08c21676 --- /dev/null +++ b/src/server/alidrive.ts @@ -0,0 +1,452 @@ +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import needle from 'needle' + +const API_BASE = 'https://openapi.alipan.com' +const CONFIG_FILE = 'alidrive.json' +const DEFAULT_SCOPES = ['user:base', 'file:all:read', 'file:all:write'] + +interface AlidriveConfig { + clientId: string + clientSecret: string + accessToken: string + refreshToken: string + tokenType: string + expiresAt: number + driveId: string + userName: string + linked: boolean + linkedAt: number +} + +interface QrStatus { + status: 'PendingLogin' | 'Scanning' | 'LoginSuccess' | 'Expired' | 'Cancel' | 'Refreshed' + auth_code?: string + state?: string + error_message?: string +} + +const defaultConfig: AlidriveConfig = { + clientId: '', + clientSecret: '', + accessToken: '', + refreshToken: '', + tokenType: 'Bearer', + expiresAt: 0, + driveId: '', + userName: '', + linked: false, + linkedAt: 0, +} + +let config: AlidriveConfig = { ...defaultConfig } + +const configPath = () => path.join(global.lx.dataPath, CONFIG_FILE) + +export const loadConfig = (): AlidriveConfig => { + const p = configPath() + if (fs.existsSync(p)) { + try { + config = { ...defaultConfig, ...JSON.parse(fs.readFileSync(p, 'utf8')) } + } catch (e) { + config = { ...defaultConfig } + } + } + return config +} + +export const saveConfig = (): void => { + try { + fs.writeFileSync(configPath(), JSON.stringify(config, null, 2), 'utf8') + } catch (e) { + console.error('[Alidrive] Failed to save config:', e) + } +} + +export const getConfig = (): AlidriveConfig => config + +export const updateClient = (clientId: string, clientSecret: string): void => { + config.clientId = clientId || '' + config.clientSecret = clientSecret || '' + if (clientId && clientSecret) { + config.linked = false + config.accessToken = '' + config.refreshToken = '' + config.driveId = '' + config.userName = '' + } + saveConfig() +} + +const basicAuthHeader = (): string => { + const raw = `${config.clientId}:${config.clientSecret}` + return `Basic ${Buffer.from(raw).toString('base64')}` +} + +const isExpired = (): boolean => { + if (!config.accessToken) return true + if (!config.expiresAt) return true + return Date.now() >= config.expiresAt +} + +const request = (method: string, url: string, data?: any, headers?: any): Promise => { + return new Promise((resolve, reject) => { + const opts: any = { json: true, timeout: 30000, headers: {} } + if (headers) opts.headers = { ...headers } + needle.request(method as any, url, data, opts, (err: any, resp: any) => { + if (err) return reject(new Error(err.message || 'Network error')) + const body = resp.body + if (resp.statusCode && resp.statusCode >= 400) { + const msg = body && (body.message || body.error_description || body.error) || `HTTP ${resp.statusCode}` + const err2: any = new Error(msg) + err2.code = resp.statusCode + err2.body = body + return reject(err2) + } + resolve(body) + }) + }) +} + +const apiRequest = (method: string, urlPath: string, data?: any): Promise => { + return request(method, `${API_BASE}${urlPath}`, data, { + Authorization: `${config.tokenType || 'Bearer'} ${config.accessToken}`, + }) +} + +/** + * 刷新 access token + */ +export const refreshToken = async (): Promise => { + if (!config.clientId || !config.clientSecret || !config.refreshToken) return false + try { + const res = await request('POST', `${API_BASE}/oauth/token`, { + grant_type: 'refresh_token', + refresh_token: config.refreshToken, + client_id: config.clientId, + }, { + Authorization: basicAuthHeader(), + }) + if (res && res.access_token) { + config.accessToken = res.access_token + config.refreshToken = res.refresh_token || config.refreshToken + config.expiresAt = Date.now() + (res.expires_in || 7200) * 1000 + saveConfig() + return true + } + return false + } catch (e) { + console.error('[Alidrive] Refresh token failed:', (e as any).message) + return false + } +} + +/** + * 确保有效的 access token + */ +export const ensureToken = async (): Promise => { + if (!config.clientId || !config.clientSecret) return false + if (config.accessToken && !isExpired()) return true + if (!config.refreshToken) return false + return refreshToken() +} + +/** + * 获取 driveId(缓存到配置中) + */ +export const ensureDriveId = async (): Promise => { + if (config.driveId) return config.driveId + if (!(await ensureToken())) return '' + try { + const res = await apiRequest('POST', '/adrive/v1.0/user/getDriveInfo', {}) + if (res && res.default_drive_id) { + config.driveId = res.default_drive_id + config.userName = res.name || config.userName + config.linked = true + saveConfig() + return config.driveId + } + return '' + } catch (e) { + console.error('[Alidrive] Get drive info failed:', (e as any).message) + return '' + } +} + +// ===== 扫码登录流程 ===== + +/** + * 步骤1: 创建扫码授权,返回二维码内容 qr_content 与 sid + */ +export const createQrCode = async (): Promise<{ qr_content: string; sid: string }> => { + if (!config.clientId || !config.clientSecret) { + throw new Error('阿里云盘未配置 ClientID / ClientSecret,请先在后台管理配置') + } + const res = await request('POST', `${API_BASE}/oauth/authorize/qrcode`, { + client_id: config.clientId, + scopes: DEFAULT_SCOPES, + state: cryptoRandomStr(16), + }, { + Authorization: basicAuthHeader(), + }) + if (!res || !res.qr_content || !res.sid) { + throw new Error('创建扫码授权失败: ' + JSON.stringify(res || {})) + } + return { qr_content: res.qr_content, sid: res.sid } +} + +/** + * 步骤2: 轮询扫码状态 + */ +export const checkQrStatus = async (sid: string): Promise => { + if (!config.clientId) throw new Error('未配置 ClientID') + const res = await request('POST', `${API_BASE}/oauth/qrcode/${sid}/status`, { + client_id: config.clientId, + }, { + Authorization: basicAuthHeader(), + }) + return res as QrStatus +} + +/** + * 步骤3: 用 auth_code 兑换 token + */ +export const exchangeToken = async (code: string): Promise => { + if (!config.clientId || !config.clientSecret) return false + const res = await request('POST', `${API_BASE}/oauth/token`, { + grant_type: 'authorization_code', + code, + client_id: config.clientId, + }, { + Authorization: basicAuthHeader(), + }) + if (res && res.access_token) { + config.accessToken = res.access_token + config.refreshToken = res.refresh_token || config.refreshToken + config.expiresAt = Date.now() + (res.expires_in || 7200) * 1000 + config.linked = true + config.linkedAt = Date.now() + saveConfig() + await ensureDriveId() + return true + } + return false +} + +/** + * 解除绑定 + */ +export const unlink = (): void => { + config.accessToken = '' + config.refreshToken = '' + config.driveId = '' + config.userName = '' + config.linked = false + config.linkedAt = 0 + saveConfig() +} + +const cryptoRandomStr = (len: number): string => { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + let result = '' + const bytes = crypto.randomBytes(len) + for (let i = 0; i < len; i++) result += chars[bytes[i] % chars.length] + return result +} + +// ===== 文件 API ===== + +/** + * 获取文件列表 + */ +export const listFiles = async (parentFileId = 'root', marker = '', limit = 100): Promise<{ items: any[]; next_marker: string }> => { + const driveId = await ensureDriveId() + if (!driveId) throw new Error('阿里云盘未授权') + const res = await apiRequest('POST', '/adrive/v1.0/openFile/list', { + drive_id: driveId, + parent_file_id: parentFileId, + limit, + marker, + order_by: 'name', + order_direction: 'ASC', + }) + return { items: res?.items || [], next_marker: res?.next_marker || '' } +} + +/** + * 搜索文件(query 形如: name match "关键字") + */ +export const searchFiles = async (query: string, marker = '', limit = 50): Promise<{ items: any[]; next_marker: string }> => { + const driveId = await ensureDriveId() + if (!driveId) throw new Error('阿里云盘未授权') + const res = await apiRequest('POST', '/adrive/v1.0/openFile/search', { + drive_id: driveId, + query, + limit, + marker, + }) + return { items: res?.items || [], next_marker: res?.next_marker || '' } +} + +/** + * 获取单个文件的下载地址 + */ +export const getDownloadUrl = async (fileId: string): Promise => { + const driveId = await ensureDriveId() + if (!driveId) throw new Error('阿里云盘未授权') + const res = await apiRequest('POST', '/adrive/v1.0/openFile/getDownloadUrl', { + drive_id: driveId, + file_id: fileId, + }) + if (res && res.url) return res.url + throw new Error('获取下载地址失败') +} + +/** + * 获取文件元信息 + */ +export const getFileInfo = async (fileId: string): Promise => { + const driveId = await ensureDriveId() + if (!driveId) throw new Error('阿里云盘未授权') + const res = await apiRequest('POST', '/adrive/v1.0/openFile/get', { + drive_id: driveId, + file_id: fileId, + }) + return res || {} +} + +/** + * 创建文件夹 + */ +export const createFolder = async (parentFileId: string, name: string): Promise => { + const driveId = await ensureDriveId() + if (!driveId) throw new Error('阿里云盘未授权') + const res = await apiRequest('POST', '/adrive/v1.0/openFile/create', { + drive_id: driveId, + parent_file_id: parentFileId, + name, + type: 'folder', + check_name_mode: 'refuse', + }) + if (res && res.file_id) return res.file_id + throw new Error('创建文件夹失败') +} + +const getOrCreateFolderId = async (parentFileId: string, folderName: string): Promise => { + try { + const { items } = await listFiles(parentFileId) + const match = items.find((it: any) => it.type === 'folder' && it.name === folderName) + if (match) return match.file_id + } catch (e) { /* ignore */ } + return createFolder(parentFileId, folderName) +} + +/** + * 确保目录结构存在,返回最终目录 file_id + * 支持 "/a/b/c" 形式的路径,从根目录依次创建 + */ +export const ensureDirPath = async (dirPath: string): Promise => { + const driveId = await ensureDriveId() + if (!driveId) throw new Error('阿里云盘未授权') + const segments = (dirPath || '').split('/').filter(s => s && s !== '.') + let parentId = 'root' + for (const seg of segments) { + parentId = await getOrCreateFolderId(parentId, seg) + } + return parentId +} + +/** + * 上传文件(直传模式,单分片) + * 返回上传后的 file_id + */ +export const uploadFile = async (parentFileId: string, fileName: string, filePath: string): Promise => { + const driveId = await ensureDriveId() + if (!driveId) throw new Error('阿里云盘未授权') + const stat = fs.statSync(filePath) + const size = stat.size + const createRes = await apiRequest('POST', '/adrive/v1.0/openFile/create', { + drive_id: driveId, + parent_file_id: parentFileId, + name: fileName, + type: 'file', + check_name_mode: 'ignore', + part_info_list: [{ part_number: 1 }], + size, + }) + if (!createRes || !createRes.file_id || !createRes.upload_id) { + throw new Error('创建上传任务失败: ' + JSON.stringify(createRes || {})) + } + const uploadUrl = createRes.part_info_list?.[0]?.upload_url + if (!uploadUrl) throw new Error('获取上传地址失败') + + await new Promise((resolve, reject) => { + const fileStream = fs.createReadStream(filePath) + const opts: any = { + timeout: 0, + headers: { 'Content-Type': 'application/octet-stream' }, + } + const uploadReq = needle.put(uploadUrl, fileStream, opts, (err: any, resp: any) => { + if (err) return reject(new Error(err.message || 'Upload failed')) + if (resp.statusCode && resp.statusCode >= 400) { + return reject(new Error(`Upload failed: HTTP ${resp.statusCode}`)) + } + resolve(null) + }) + uploadReq.on('error', (e: any) => reject(e)) + }) + + const completeRes = await apiRequest('POST', '/adrive/v1.0/openFile/complete', { + drive_id: driveId, + file_id: createRes.file_id, + upload_id: createRes.upload_id, + part_info_list: [{ part_number: 1 }], + }) + if (completeRes && completeRes.file_id) return completeRes.file_id + throw new Error('完成上传失败') +} + +/** + * 上传缓冲区内容为文件(用于歌词/小文件) + */ +export const uploadBuffer = async (parentFileId: string, fileName: string, buffer: Buffer): Promise => { + const driveId = await ensureDriveId() + if (!driveId) throw new Error('阿里云盘未授权') + const createRes = await apiRequest('POST', '/adrive/v1.0/openFile/create', { + drive_id: driveId, + parent_file_id: parentFileId, + name: fileName, + type: 'file', + check_name_mode: 'ignore', + part_info_list: [{ part_number: 1 }], + size: buffer.length, + }) + if (!createRes || !createRes.file_id || !createRes.upload_id) { + throw new Error('创建上传任务失败') + } + const uploadUrl = createRes.part_info_list?.[0]?.upload_url + if (!uploadUrl) throw new Error('获取上传地址失败') + + await new Promise((resolve, reject) => { + needle.put(uploadUrl, buffer, { + timeout: 0, + headers: { 'Content-Type': 'application/octet-stream' }, + }, (err: any, resp: any) => { + if (err) return reject(new Error(err.message || 'Upload failed')) + if (resp.statusCode && resp.statusCode >= 400) return reject(new Error(`Upload failed: HTTP ${resp.statusCode}`)) + resolve(null) + }) + }) + + const completeRes = await apiRequest('POST', '/adrive/v1.0/openFile/complete', { + drive_id: driveId, + file_id: createRes.file_id, + upload_id: createRes.upload_id, + part_info_list: [{ part_number: 1 }], + }) + if (completeRes && completeRes.file_id) return completeRes.file_id + throw new Error('完成上传失败') +} + +export const isLinked = (): boolean => !!(config.linked && config.clientId && config.accessToken) diff --git a/src/server/cards.ts b/src/server/cards.ts new file mode 100644 index 00000000..4e151b59 --- /dev/null +++ b/src/server/cards.ts @@ -0,0 +1,116 @@ +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' + +const CARDS_FILE = 'cards.json' + +interface Card { + id: string + code: string + status: 'unused' | 'used' + createdAt: number + usedAt: number | null + boundUser: string | null + remark: string | null + expireDays: number | null +} + +let cards: Card[] = [] + +const cardsPath = () => path.join(global.lx.dataPath, CARDS_FILE) + +const loadCards = (): void => { + const p = cardsPath() + if (fs.existsSync(p)) { + try { + cards = JSON.parse(fs.readFileSync(p, 'utf8')) + if (!Array.isArray(cards)) cards = [] + } catch (e) { + cards = [] + } + } +} + +const saveCards = (): void => { + try { + fs.writeFileSync(cardsPath(), JSON.stringify(cards, null, 2), 'utf8') + } catch (e) { + console.error('[Cards] Failed to save cards:', e) + } +} + +const generateCode = (): string => { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + let code = '' + const bytes = crypto.randomBytes(16) + for (let i = 0; i < 16; i++) { + code += alphabet[bytes[i] % alphabet.length] + if (i % 4 === 3 && i < 15) code += '-' + } + return code +} + +/** + * 批量生成卡密 + */ +export const generateCards = (count: number, expireDays: number | null, remark: string | null): Card[] => { + loadCards() + const now = Date.now() + const newCards: Card[] = [] + for (let i = 0; i < count; i++) { + newCards.push({ + id: crypto.randomBytes(8).toString('hex'), + code: generateCode(), + status: 'unused', + createdAt: now, + usedAt: null, + boundUser: null, + remark: remark || null, + expireDays: expireDays && expireDays > 0 ? expireDays : null, + }) + } + cards = [...cards, ...newCards] + saveCards() + return newCards +} + +export const listCards = (): Card[] => { + loadCards() + return cards.slice().sort((a, b) => b.createdAt - a.createdAt) +} + +/** + * 删除卡密(支持批量) + */ +export const deleteCards = (ids: string[]): number => { + loadCards() + const before = cards.length + cards = cards.filter(c => !ids.includes(c.id)) + saveCards() + return before - cards.length +} + +/** + * 校验并占用一张卡密 + * @returns 占用成功返回 true,失败抛出错误 + */ +export const consumeCard = (code: string, boundUser: string): boolean => { + loadCards() + const normalized = code.trim().toUpperCase() + const card = cards.find(c => c.code === normalized) + if (!card) throw new Error('卡密不存在') + if (card.status === 'used') throw new Error('卡密已被使用') + if (card.expireDays) { + const expireAt = card.createdAt + card.expireDays * 24 * 60 * 60 * 1000 + if (Date.now() > expireAt) throw new Error('卡密已过期') + } + card.status = 'used' + card.usedAt = Date.now() + card.boundUser = boundUser + saveCards() + return true +} + +export const initCards = (): void => { + loadCards() +} diff --git a/src/server/openlist.ts b/src/server/openlist.ts new file mode 100644 index 00000000..8928ef7f --- /dev/null +++ b/src/server/openlist.ts @@ -0,0 +1,369 @@ +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import needle from 'needle' + +const CONFIG_FILE = 'openlist.json' + +interface OpenListServer { + id: string + name: string + baseUrl: string + username: string + password: string + token: string + rootPath: string + enabled: boolean + createdAt: number +} + +interface OpenListConfig { + servers: OpenListServer[] +} + +const defaultConfig: OpenListConfig = { + servers: [], +} + +let config: OpenListConfig = { servers: [] } + +const configPath = () => path.join(global.lx.dataPath, CONFIG_FILE) + +// token 缓存:服务器 id -> token +const tokenCache: Record = {} + +const now = () => Date.now() + +export const loadConfig = (): OpenListConfig => { + const p = configPath() + if (fs.existsSync(p)) { + try { + const parsed = JSON.parse(fs.readFileSync(p, 'utf8')) + config = { + servers: Array.isArray(parsed.servers) ? parsed.servers : [], + } + } catch (e) { + config = { servers: [] } + } + } + return config +} + +export const saveConfig = (): void => { + try { + fs.writeFileSync(configPath(), JSON.stringify(config, null, 2), 'utf8') + } catch (e) { + console.error('[OpenList] Failed to save config:', e) + } +} + +export const getConfig = (): OpenListConfig => config + +export const listServers = (): OpenListServer[] => { + loadConfig() + return config.servers.slice().sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)) +} + +export const getServer = (id: string): OpenListServer | null => { + loadConfig() + return config.servers.find(s => s.id === id) || null +} + +const normalizeServer = (data: any): OpenListServer => { + let baseUrl = String(data.baseUrl || '').trim().replace(/\/+$/, '') + if (baseUrl && !/^https?:\/\//i.test(baseUrl)) baseUrl = 'https://' + baseUrl + return { + id: data.id || crypto.randomBytes(8).toString('hex'), + name: String(data.name || 'OpenList').trim(), + baseUrl, + username: String(data.username || '').trim(), + password: String(data.password || ''), + token: String(data.token || '').trim(), + rootPath: String(data.rootPath || '/').trim() || '/', + enabled: data.enabled !== false, + createdAt: data.createdAt || now(), + } +} + +export const addServer = (data: any): OpenListServer => { + loadConfig() + if (!data.baseUrl) throw new Error('缺少 OpenList 地址') + const server = normalizeServer(data) + config.servers.push(server) + saveConfig() + return server +} + +export const updateServer = (id: string, data: any): OpenListServer | null => { + loadConfig() + const idx = config.servers.findIndex(s => s.id === id) + if (idx < 0) return null + const merged = normalizeServer({ ...config.servers[idx], ...data, id }) + config.servers[idx] = merged + delete tokenCache[id] + saveConfig() + return merged +} + +export const deleteServer = (id: string): boolean => { + loadConfig() + const before = config.servers.length + config.servers = config.servers.filter(s => s.id !== id) + delete tokenCache[id] + saveConfig() + return config.servers.length < before +} + +const encodePath = (p: string): string => { + const cleaned = p || '/' + const segments = cleaned.split('/').filter(Boolean) + return '/' + segments.map(s => encodeURIComponent(s)).join('/') +} + +const request = (server: OpenListServer, method: string, urlPath: string, data?: any, headers?: any, isDownload = false): Promise => { + return new Promise((resolve, reject) => { + const baseUrl = server.baseUrl || '' + const opts: any = { json: !isDownload, timeout: 30000, headers: {} } + if (headers) opts.headers = { ...headers } + needle.request(method as any, `${baseUrl}${urlPath}`, isDownload ? data : data, opts, (err: any, resp: any) => { + if (err) return reject(new Error(err.message || 'Network error')) + const body = resp.body + if (resp.statusCode && resp.statusCode >= 400) { + const msg = body && (body.message || body.error || body.error_description) || `HTTP ${resp.statusCode}` + const err2: any = new Error(msg) + err2.code = resp.statusCode + err2.body = body + return reject(err2) + } + if (isDownload) return resolve(resp) + if (body && typeof body === 'object' && body.code !== undefined) { + if (body.code === 200) return resolve(body.data !== undefined ? body.data : body) + const msg = body.message || body.error || `OpenList error ${body.code}` + const err3: any = new Error(msg) + err3.code = body.code + return reject(err3) + } + resolve(body) + }) + }) +} + +/** + * 登录获取 token(使用配置的用户名/密码) + */ +export const login = async (server: OpenListServer): Promise => { + if (!server.username || !server.password) throw new Error('未配置用户名/密码,无法登录') + const res = await request(server, 'POST', '/api/auth/login', { + username: server.username, + password: server.password, + }) + const token = res && (res.token || (res.data && res.data.token)) + if (!token) throw new Error('登录失败: ' + JSON.stringify(res || {})) + tokenCache[server.id] = { token, expireAt: now() + 3600 * 1000 } + return token +} + +/** + * 获取有效的 Authorization 值: + * 优先使用手动 token;否则尝试用户名/密码登录;都没有则返回空(guest 访问) + */ +export const ensureToken = async (server: OpenListServer): Promise => { + if (server.token) return server.token + const cached = tokenCache[server.id] + if (cached && cached.token && cached.expireAt > now()) return cached.token + if (server.username && server.password) { + try { + return await login(server) + } catch (e) { + console.error('[OpenList] login failed:', (e as any).message) + return '' + } + } + return '' +} + +/** + * 获取文件列表 + */ +export const listFiles = async (server: OpenListServer, dirPath: string, page = 1, perPage = 0): Promise => { + const token = await ensureToken(server) + const headers: Record = {} + if (token) headers['Authorization'] = token + const res = await request(server, 'POST', '/api/fs/list', { + path: dirPath || '/', + password: '', + page, + per_page: perPage, + refresh: false, + }, headers) + return res || { content: [], total: 0, write: false } +} + +/** + * 搜索文件(仅对当前服务器内搜索) + */ +export const searchFiles = async (server: OpenListServer, keyword: string, page = 1, perPage = 0): Promise => { + const token = await ensureToken(server) + const headers: Record = {} + if (token) headers['Authorization'] = token + try { + const res = await request(server, 'POST', '/api/fs/search', { + parent: server.rootPath || '/', + keywords: keyword, + page, + per_page: perPage, + scope: 0, + }, headers) + return res || { content: [], total: 0, write: false } + } catch (e: any) { + if (e && (e.code === 404 || e.code === 400)) { + return { content: [], total: 0, write: false } + } + throw e + } +} + +/** + * 获取文件的下载链接(/d/ 直链,带 sign) + */ +export const getDownloadUrl = async (server: OpenListServer, filePath: string, sign?: string): Promise => { + let s = sign || '' + if (!s) { + try { + const token = await ensureToken(server) + const headers: Record = {} + if (token) headers['Authorization'] = token + const info = await request(server, 'POST', '/api/fs/get', { + path: filePath, + password: '', + }, headers) + s = info && info.sign ? info.sign : '' + } catch (e) { + s = '' + } + } + const q = s ? `?sign=${encodeURIComponent(s)}` : '' + return `${server.baseUrl}/d${encodePath(filePath)}${q}` +} + +/** + * 代理下载/流式播放(支持 Range),返回 needle 请求 + */ +export const stream = async (server: OpenListServer, filePath: string, sign: string | undefined, range?: string): Promise => { + const url = await getDownloadUrl(server, filePath, sign) + const headers: Record = { + 'User-Agent': 'lxserver/1.0', + } + const token = await ensureToken(server) + if (token) headers['Authorization'] = token + if (range) headers['Range'] = range + return needle.get(url, { headers, follow_max: 10, timeout: 0 }) +} + +/** + * 获取同目录歌词(path 形如 /dir/song.mp3,找 /dir/song.lrc) + */ +export const getLyric = async (server: OpenListServer, filePath: string, sign?: string): Promise => { + const dir = path.posix.dirname(filePath === '/' ? '/' : filePath) + const baseName = path.posix.basename(filePath || '').replace(/\.[^.]+$/, '') + const lyricName = baseName + '.lrc' + const token = await ensureToken(server) + const headers: Record = {} + if (token) headers['Authorization'] = token + const list = await request(server, 'POST', '/api/fs/list', { + path: dir || '/', + password: '', + page: 1, + per_page: 0, + refresh: false, + }, headers) + const content: any[] = (list && list.content) || [] + const match = content.find((it: any) => !it.is_dir && it.name && it.name.toLowerCase() === lyricName.toLowerCase()) + if (!match) return '' + const lyricUrl = await getDownloadUrl(server, path.posix.join(dir, match.name).replace(/\/{2,}/g, '/'), match.sign) + const resp = await new Promise((resolve, reject) => { + const h: Record = {} + if (token) h['Authorization'] = token + needle.get(lyricUrl, { headers: h, timeout: 20000, json: false }, (err: any, r: any) => { + if (err) return reject(err) + resolve(r) + }) + }) + const text = resp && resp.body + if (typeof text === 'string') return text + if (Buffer.isBuffer(text)) return text.toString('utf-8') + if (text) { + try { return JSON.stringify(text) } catch (e) { return String(text) } + } + return '' +} + +/** + * 上传歌曲到 OpenList:将 sourceUrl 下载到临时文件,再 PUT 到目标目录 + */ +export const uploadFromUrl = async (server: OpenListServer, sourceUrl: string, fileName: string, dirPath: string, tmpDir: string): Promise => { + const token = await ensureToken(server) + if (!token) throw new Error('OpenList 需要登录或配置 token 才能上传') + const safeName = String(fileName || 'song.mp3').replace(/[\\/:*?"<>|]/g, '_') + const tmpFile = path.join(tmpDir, `${now()}_${Math.random().toString(36).slice(2, 8)}_${safeName}`) + try { + await new Promise((resolve, reject) => { + const fileStream = fs.createWriteStream(tmpFile) + const downloadReq = needle.get(sourceUrl, { timeout: 0, follow_max: 5 }) + downloadReq.on('error', (e: any) => reject(e)) + downloadReq.pipe(fileStream) + fileStream.on('finish', () => resolve()) + fileStream.on('error', (e: any) => reject(e)) + }) + const targetDir = (dirPath || server.rootPath || '/music').replace(/\/{2,}/g, '/') + const targetPath = path.posix.join(targetDir, safeName).replace(/\/{2,}/g, '/') + const resp = await new Promise((resolve, reject) => { + const fileStream = fs.createReadStream(tmpFile) + const opts: any = { + timeout: 0, + headers: { + 'Content-Type': 'application/octet-stream', + 'Authorization': token, + }, + } + const putReq = needle.put(`${server.baseUrl}/api/fs/put?path=${encodeURIComponent(targetPath)}`, fileStream, opts, (err: any, r: any) => { + if (err) return reject(new Error(err.message || 'Upload failed')) + if (r.statusCode && r.statusCode >= 400) { + const msg = r.body && (r.body.message || r.body.error) || `HTTP ${r.statusCode}` + return reject(new Error(msg)) + } + resolve(r.body) + }) + putReq.on('error', (e: any) => reject(e)) + }) + try { fs.unlinkSync(tmpFile) } catch (e) { /* ignore */ } + return resp + } catch (e: any) { + try { fs.unlinkSync(tmpFile) } catch (e2) { /* ignore */ } + throw e + } +} + +/** + * 测试连接:验证 baseUrl 可达且能列出根目录 + */ +export const testConnection = async (id: string): Promise<{ ok: boolean; message: string }> => { + const server = getServer(id) + if (!server) return { ok: false, message: '服务器不存在' } + try { + const token = await ensureToken(server) + const headers: Record = {} + if (token) headers['Authorization'] = token + const res = await request(server, 'POST', '/api/fs/list', { + path: server.rootPath || '/', + password: '', + page: 1, + per_page: 1, + refresh: false, + }, headers) + const content: any[] = (res && res.content) || [] + return { ok: true, message: `连接成功,共 ${res && res.total !== undefined ? res.total : content.length} 项` } + } catch (e: any) { + return { ok: false, message: e.message || '连接失败' } + } +} diff --git a/src/server/server.ts b/src/server/server.ts index 1390ffe0..00b3d658 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -28,11 +28,15 @@ import { getDownloadQualityCandidates } from './downloadQuality' import crypto from 'node:crypto' import needle from 'needle' const { MusicTagger, MetaPicture } = require('music-tag-native') +import * as alidrive from './alidrive' +import * as cards from './cards' +import * as openlist from './openlist' // ===== Player Session Store ===== const playerSessions = new Map() const SESSION_TTL = 24 * 60 * 60 * 1000 // 24小时 const SESSION_COOKIE_NAME = 'lx_player_session' +const USER_TOKEN_COOKIE_NAME = 'lx_player_user_token' /** 生成随机 sessionId */ const generateSessionId = () => crypto.randomBytes(32).toString('hex') @@ -50,17 +54,27 @@ const parseCookies = (cookieHeader: string | undefined): Record /** 检查请求是否携带有效的 Player Session Cookie */ const checkPlayerAuth = (req: IncomingMessage): boolean => { - if (!global.lx.config['player.enableAuth']) return true // 未开启认证,直接放行 + if (!global.lx.config['player.enableAuth'] && !global.lx.config['player.forceLogin']) return true // 未开启认证,直接放行 const cookies = parseCookies(req.headers['cookie']) const sessionId = cookies[SESSION_COOKIE_NAME] - if (!sessionId) return false - const session = playerSessions.get(sessionId) - if (!session) return false - if (Date.now() - session.createdAt > SESSION_TTL) { - playerSessions.delete(sessionId) - return false + if (sessionId) { + const session = playerSessions.get(sessionId) + if (session) { + if (Date.now() - session.createdAt > SESSION_TTL) { + playerSessions.delete(sessionId) + } else { + return true + } + } } - return true + // 兼容:播放器账号登录后同时校验 user token cookie + const userTokenCookie = cookies[USER_TOKEN_COOKIE_NAME] + if (userTokenCookie) { + const session = userSessions.get(userTokenCookie) + if (session && Date.now() - session.createdAt <= USER_SESSION_TTL) return true + if (persistentTokens.get(userTokenCookie)) return true + } + return false } /** 定期清理过期 Session(每小时) */ @@ -974,8 +988,8 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro pathname === `${normalizedPrefix}/sw.js` || isLegacyPlayerAsset - // 认证检查 - if (!isLoginPage && !isPublicAsset && global.lx.config['player.enableAuth']) { + // 认证检查(密码认证 或 强制登录) + if (!isLoginPage && !isPublicAsset && (global.lx.config['player.enableAuth'] || global.lx.config['player.forceLogin'])) { if (!checkPlayerAuth(req)) { res.writeHead(302, { 'Location': `${normalizedPrefix}/login` }) res.end() @@ -1846,7 +1860,16 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro const token = generateSessionId() userSessions.set(token, { username, createdAt: Date.now() }) loginLog.info(`User token issued: ${username} from ${ip}`) - res.writeHead(200, { 'Content-Type': 'application/json' }) + const loginHeaders: Record = { 'Content-Type': 'application/json' } + if (global.lx.config['player.forceLogin']) { + const sessionId = generateSessionId() + playerSessions.set(sessionId, { createdAt: Date.now() }) + const cookies: string[] = [] + cookies.push(`${SESSION_COOKIE_NAME}=${sessionId}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${SESSION_TTL / 1000}`) + cookies.push(`${USER_TOKEN_COOKIE_NAME}=${token}; Path=/; SameSite=Lax; Max-Age=${USER_SESSION_TTL / 1000}`) + loginHeaders['Set-Cookie'] = cookies.join(', ') + } + res.writeHead(200, loginHeaders) res.end(JSON.stringify({ success: true, token, username })) } else { loginLog.warn(`User login failed: ${username} from ${ip}`) @@ -4223,6 +4246,9 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro }) res.end(JSON.stringify({ 'player.enableAuth': global.lx.config['player.enableAuth'] || false, + 'player.forceLogin': global.lx.config['player.forceLogin'] ?? true, + 'player.enableRegister': global.lx.config['player.enableRegister'] ?? true, + 'player.enableAlidrive': global.lx.config['player.enableAlidrive'] ?? true, 'user.enablePublicRestriction': global.lx.config['user.enablePublicRestriction'] || false, 'user.enablePublicFavorites': global.lx.config['user.enablePublicFavorites'] || false, 'user.enablePublicNonAdminAccess': global.lx.config['user.enablePublicNonAdminAccess'] || false, @@ -4280,6 +4306,893 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro return } + // ================= 卡密注册 ================= + // [新增] 用户注册(需卡密) + if (pathname === '/api/auth/register' && req.method === 'POST') { + void readBody(req).then(async body => { + try { + const { username, password, cardCode } = JSON.parse(body) + if (!username || !password || !cardCode) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少用户名、密码或卡密' })) + return + } + if (typeof username !== 'string' || !/^[a-zA-Z0-9_\-]{2,32}$/.test(username)) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '用户名仅支持字母/数字/下划线/短横线,长度2-32' })) + return + } + if (typeof password !== 'string' || password.length < 6) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '密码长度至少6位' })) + return + } + if (global.lx.config.users.some(u => u.name === username)) { + res.writeHead(409, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '用户名已存在' })) + return + } + if (global.lx.config['player.enableRegister'] === false) { + res.writeHead(403, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '注册功能已关闭' })) + return + } + try { + cards.consumeCard(cardCode, username) + } catch (err: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + return + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getUserDirname } = require('@/user') + const dataPath = path.join(global.lx.userPath, getUserDirname(username)) + checkAndCreateDir(dataPath) + + global.lx.config.users.push({ name: username, password, dataPath }) + saveUsers() + + const token = generateSessionId() + userSessions.set(token, { username, createdAt: Date.now() }) + loginLog.info(`New user registered: ${username} from ${ip}`) + const regHeaders: Record = { 'Content-Type': 'application/json' } + if (global.lx.config['player.forceLogin']) { + const sessionId = generateSessionId() + playerSessions.set(sessionId, { createdAt: Date.now() }) + const cookies: string[] = [] + cookies.push(`${SESSION_COOKIE_NAME}=${sessionId}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${SESSION_TTL / 1000}`) + cookies.push(`${USER_TOKEN_COOKIE_NAME}=${token}; Path=/; SameSite=Lax; Max-Age=${USER_SESSION_TTL / 1000}`) + regHeaders['Set-Cookie'] = cookies.join(', ') + } + res.writeHead(200, regHeaders) + res.end(JSON.stringify({ success: true, token, username })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // ================= 卡密管理(管理员) ================= + const requireAdminAuth = () => (req.headers['x-frontend-auth'] as string) === global.lx.config['frontend.password'] + + if (pathname === '/api/card/list' && req.method === 'GET') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, cards: cards.listCards() })) + return + } + + if (pathname === '/api/card/generate' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const { count, expireDays, remark } = JSON.parse(body) + const num = Math.min(Math.max(parseInt(count) || 1, 1), 500) + const created = cards.generateCards(num, expireDays ? parseInt(expireDays) : null, remark || null) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, cards: created })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + if (pathname === '/api/card/delete' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const { ids } = JSON.parse(body) + const deleted = cards.deleteCards(Array.isArray(ids) ? ids : []) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, deleted })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // ================= 阿里云盘 ================= + // [新增] 查询阿里云盘绑定状态(无需管理员) + if (pathname === '/api/alidrive/status' && req.method === 'GET') { + const cfg = alidrive.getConfig() + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + success: true, + linked: alidrive.isLinked(), + userName: cfg.userName || '', + hasClient: !!(cfg.clientId && cfg.clientSecret), + })) + return + } + + // [新增] 获取/保存阿里云盘 ClientID/ClientSecret(管理员) + if (pathname === '/api/alidrive/config' && req.method === 'GET') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const cfg = alidrive.getConfig() + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + success: true, + clientId: cfg.clientId, + clientSecret: cfg.clientSecret, + linked: alidrive.isLinked(), + userName: cfg.userName || '', + })) + return + } + + if (pathname === '/api/alidrive/config' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const { clientId, clientSecret } = JSON.parse(body) + if (!clientId || !clientSecret) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 ClientID 或 ClientSecret' })) + return + } + alidrive.updateClient(clientId, clientSecret) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // [新增] 创建阿里云盘扫码登录二维码(管理员) + if (pathname === '/api/alidrive/qrcode' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + alidrive.createQrCode().then(({ qr_content, sid }) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, qr_content, sid })) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] 轮询扫码状态(管理员) + if (pathname === '/api/alidrive/qrcode/status' && req.method === 'GET') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const sid = urlObj.searchParams.get('sid') + if (!sid) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 sid' })) + return + } + alidrive.checkQrStatus(sid).then(async (statusObj) => { + if (statusObj.status === 'LoginSuccess' && statusObj.auth_code) { + const ok = await alidrive.exchangeToken(statusObj.auth_code) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, ...statusObj, bound: ok ? 'Bound' : 'TokenExchangeFailed' })) + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, ...statusObj })) + } + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] 解除阿里云盘绑定(管理员) + if (pathname === '/api/alidrive/unlink' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + alidrive.unlink() + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true })) + return + } + + // [新增] 阿里云盘文件列表(登录用户/管理员) + const requirePlayerOrAdmin = (): string | null => { + if (requireAdminAuth()) return 'admin' + const user = verifyUserAuth(req) + if (user) return user + if (checkPlayerAuth(req)) return 'player' + return null + } + + if (pathname === '/api/alidrive/list' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const parentFileId = urlObj.searchParams.get('parentFileId') || 'root' + const marker = urlObj.searchParams.get('marker') || '' + alidrive.listFiles(parentFileId, marker).then(({ items, next_marker }) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, items, next_marker })) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] 阿里云盘文件搜索(登录用户/管理员) + if (pathname === '/api/alidrive/search' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const keyword = urlObj.searchParams.get('keyword') || '' + const marker = urlObj.searchParams.get('marker') || '' + if (!keyword) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少关键词' })) + return + } + const escaped = keyword.replace(/["\\]/g, '\\$&') + alidrive.searchFiles(`name match "${escaped}"`, marker).then(({ items, next_marker }) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, items, next_marker })) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] 阿里云盘音频流式播放(代理,支持 Range,登录用户/管理员) + if (pathname === '/api/alidrive/stream' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const fileId = urlObj.searchParams.get('fileId') + if (!fileId) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 fileId' })) + return + } + alidrive.getDownloadUrl(fileId).then((downloadUrl) => { + const headers: Record = {} + if (req.headers.range) headers['Range'] = req.headers.range as string + const proxyReq: any = needle.get(downloadUrl, { headers, follow_max: 5 }) + proxyReq.on('error', (err: any) => { + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + } else { + res.end() + } + }) + proxyReq.on('response', (resp: any) => { + const statusCode = resp.statusCode || 200 + const outHeaders: Record = {} + if (resp.headers['content-type']) outHeaders['Content-Type'] = String(resp.headers['content-type']).split(';')[0] || 'audio/mpeg' + if (resp.headers['content-length']) outHeaders['Content-Length'] = resp.headers['content-length'] + if (resp.headers['accept-ranges']) outHeaders['Accept-Ranges'] = resp.headers['accept-ranges'] + if (resp.headers['content-range']) outHeaders['Content-Range'] = resp.headers['content-range'] + outHeaders['Cache-Control'] = 'no-cache' + res.writeHead(statusCode, outHeaders) + resp.pipe(res) + }) + req.on('close', () => { + if (!proxyReq.destroyed) proxyReq.destroy() + }) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] 阿里云盘文件下载(登录用户/管理员) + if (pathname === '/api/alidrive/download' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const fileId = urlObj.searchParams.get('fileId') + if (!fileId) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 fileId' })) + return + } + alidrive.getDownloadUrl(fileId).then((downloadUrl) => { + const headers: Record = {} + if (req.headers.range) headers['Range'] = req.headers.range as string + needle.get(downloadUrl, { headers, follow_max: 5 }, (err: any, resp: any) => { + if (err) { + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + } else { + res.end() + } + return + } + const outHeaders: Record = { + 'Content-Type': String(resp.headers['content-type'] || 'application/octet-stream').split(';')[0], + } + if (resp.headers['content-length']) outHeaders['Content-Length'] = resp.headers['content-length'] + res.writeHead(resp.statusCode || 200, outHeaders) + resp.pipe(res) + }) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] 获取阿里云盘音频同目录歌词(登录用户/管理员) + if (pathname === '/api/alidrive/lyric' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const fileId = urlObj.searchParams.get('fileId') + if (!fileId) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 fileId' })) + return + } + alidrive.getFileInfo(fileId).then(async (fileInfo) => { + const lyricName = (fileInfo.name || '').replace(/\.[^.]+$/, '') + '.lrc' + const parentId = fileInfo.parent_file_id || 'root' + const { items } = await alidrive.listFiles(parentId) + const lyricFile = items.find((it: any) => it.type === 'file' && it.name.toLowerCase() === lyricName.toLowerCase()) + if (!lyricFile) { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, lyric: '' })) + return + } + const url = await alidrive.getDownloadUrl(lyricFile.file_id) + needle.get(url, { timeout: 20000 }, (err: any, resp: any) => { + if (err) { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, lyric: '' })) + return + } + const text = resp.body && typeof resp.body === 'string' ? resp.body : (resp.body ? JSON.stringify(resp.body) : '') + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, lyric: text || '' })) + }) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] 下载歌曲到阿里云盘(登录用户/管理员) + if (pathname === '/api/alidrive/upload-song' && req.method === 'POST') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(async body => { + let sourceUrl = '' + let fileName = '' + let dirPath = '' + try { + const parsed = JSON.parse(body) + sourceUrl = parsed.url + fileName = parsed.filename || 'song.mp3' + dirPath = parsed.dirPath || '/music/lxserver' + } catch (e) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Bad Request' })) + return + } + if (!sourceUrl) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少音频 URL' })) + return + } + const tmpDir = path.join(global.lx.dataPath, 'tmp') + checkAndCreateDir(tmpDir) + const tmpFile = path.join(tmpDir, `${Date.now()}_${Math.random().toString(36).slice(2, 8)}_${fileName.replace(/[\\/:*?"<>|]/g, '_')}`) + try { + await new Promise((resolve, reject) => { + const fileStream = fs.createWriteStream(tmpFile) + const downloadReq = needle.get(sourceUrl, { timeout: 0, follow_max: 5 }) + downloadReq.on('error', (e: any) => reject(e)) + downloadReq.pipe(fileStream) + fileStream.on('finish', () => resolve()) + fileStream.on('error', (e: any) => reject(e)) + }) + const dirId = await alidrive.ensureDirPath(dirPath) + const fileId = await alidrive.uploadFile(dirId, fileName, tmpFile) + try { fs.unlinkSync(tmpFile) } catch (e) { /* ignore */ } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, fileId })) + } catch (e: any) { + try { fs.unlinkSync(tmpFile) } catch (e2) { /* ignore */ } + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message })) + } + }) + return + } + + // ================= OpenList 存储 ================= + // [新增] OpenList 服务器列表(管理员) + if (pathname === '/api/openlist/servers' && req.method === 'GET') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const servers = openlist.listServers().map((s: any) => ({ + id: s.id, + name: s.name, + baseUrl: s.baseUrl, + username: s.username, + hasPassword: !!s.password, + token: s.token, + rootPath: s.rootPath, + enabled: s.enabled, + createdAt: s.createdAt, + })) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, servers })) + return + } + + // [新增] 添加 OpenList 服务器(管理员) + if (pathname === '/api/openlist/servers' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const data = JSON.parse(body) + if (!data.baseUrl) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 OpenList 地址' })) + return + } + const server = openlist.addServer(data) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, server })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // [新增] 更新 OpenList 服务器(管理员) + if (pathname === '/api/openlist/servers' && req.method === 'PUT') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const data = JSON.parse(body) + if (!data.id) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少服务器 ID' })) + return + } + const server = openlist.updateServer(data.id, data) + if (!server) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '服务器不存在' })) + return + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, server })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // [新增] 删除 OpenList 服务器(管理员) + if (pathname === '/api/openlist/servers' && req.method === 'DELETE') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const { id } = JSON.parse(body) + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少服务器 ID' })) + return + } + const ok = openlist.deleteServer(id) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, deleted: ok })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // [新增] 测试 OpenList 连接(管理员) + if (pathname === '/api/openlist/test' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const { id } = JSON.parse(body) + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少服务器 ID' })) + return + } + openlist.testConnection(id).then(result => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: result.ok, message: result.message })) + }).catch((err: any) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // [新增] OpenList 可用的服务器列表(登录用户/管理员,用于播放器选择) + if (pathname === '/api/openlist/available' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const servers = openlist.listServers() + .filter((s: any) => s.enabled) + .map((s: any) => ({ + id: s.id, + name: s.name, + baseUrl: s.baseUrl, + rootPath: s.rootPath, + hasAuth: !!s.token || !!(s.username && s.password), + })) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, servers })) + return + } + + // [新增] OpenList 文件列表(登录用户/管理员) + if (pathname === '/api/openlist/list' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const dirPath = urlObj.searchParams.get('path') || '/' + const page = parseInt(urlObj.searchParams.get('page') || '1') + const perPage = parseInt(urlObj.searchParams.get('perPage') || '0') + const server = openlist.getServer(serverId) + if (!server) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '服务器不存在' })) + return + } + openlist.listFiles(server, dirPath, page, perPage).then((data) => { + const items = (data && data.content) || [] + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + success: true, + items, + total: data && data.total !== undefined ? data.total : items.length, + write: !!(data && data.write), + provider: data && data.provider || '', + })) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] OpenList 文件搜索(登录用户/管理员) + if (pathname === '/api/openlist/search' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const keyword = urlObj.searchParams.get('keyword') || '' + const page = parseInt(urlObj.searchParams.get('page') || '1') + if (!keyword) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少关键词' })) + return + } + const server = openlist.getServer(serverId) + if (!server) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '服务器不存在' })) + return + } + openlist.searchFiles(server, keyword, page).then((data) => { + const items = (data && data.content) || [] + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + success: true, + items, + total: data && data.total !== undefined ? data.total : items.length, + })) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] OpenList 音频流式播放(代理,支持 Range,登录用户/管理员) + if (pathname === '/api/openlist/stream' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const filePath = urlObj.searchParams.get('path') || '' + const sign = urlObj.searchParams.get('sign') || undefined + if (!serverId || !filePath) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 server 或 path 参数' })) + return + } + const server = openlist.getServer(serverId) + if (!server) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '服务器不存在' })) + return + } + openlist.stream(server, filePath, sign, req.headers.range as string | undefined).then((proxyReq: any) => { + proxyReq.on('error', (err: any) => { + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + } else { + res.end() + } + }) + proxyReq.on('response', (resp: any) => { + const statusCode = resp.statusCode || 200 + const outHeaders: Record = {} + if (resp.headers['content-type']) outHeaders['Content-Type'] = String(resp.headers['content-type']).split(';')[0] || 'audio/mpeg' + if (resp.headers['content-length']) outHeaders['Content-Length'] = resp.headers['content-length'] + if (resp.headers['accept-ranges']) outHeaders['Accept-Ranges'] = resp.headers['accept-ranges'] + if (resp.headers['content-range']) outHeaders['Content-Range'] = resp.headers['content-range'] + outHeaders['Cache-Control'] = 'no-cache' + res.writeHead(statusCode, outHeaders) + resp.pipe(res) + }) + req.on('close', () => { + if (!proxyReq.destroyed) proxyReq.destroy() + }) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] OpenList 文件下载(登录用户/管理员) + if (pathname === '/api/openlist/download' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const filePath = urlObj.searchParams.get('path') || '' + const sign = urlObj.searchParams.get('sign') || undefined + if (!serverId || !filePath) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 server 或 path 参数' })) + return + } + const server = openlist.getServer(serverId) + if (!server) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '服务器不存在' })) + return + } + openlist.stream(server, filePath, sign).then((proxyReq: any) => { + proxyReq.on('error', (err: any) => { + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + } else { + res.end() + } + }) + proxyReq.on('response', (resp: any) => { + const outHeaders: Record = { + 'Content-Type': String(resp.headers['content-type'] || 'application/octet-stream').split(';')[0], + } + if (resp.headers['content-length']) outHeaders['Content-Length'] = resp.headers['content-length'] + res.writeHead(resp.statusCode || 200, outHeaders) + resp.pipe(res) + }) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] OpenList 获取同目录歌词(登录用户/管理员) + if (pathname === '/api/openlist/lyric' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const filePath = urlObj.searchParams.get('path') || '' + const sign = urlObj.searchParams.get('sign') || undefined + if (!serverId || !filePath) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 server 或 path 参数' })) + return + } + const server = openlist.getServer(serverId) + if (!server) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '服务器不存在' })) + return + } + openlist.getLyric(server, filePath, sign).then((lyric) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, lyric: lyric || '' })) + }).catch((err: any) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, lyric: '' })) + }) + return + } + + // [新增] 下载歌曲到 OpenList(登录用户/管理员) + if (pathname === '/api/openlist/upload-song' && req.method === 'POST') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + let serverId = '' + let sourceUrl = '' + let fileName = '' + let dirPath = '' + try { + const parsed = JSON.parse(body) + serverId = parsed.server + sourceUrl = parsed.url + fileName = parsed.filename || 'song.mp3' + dirPath = parsed.dirPath || '' + } catch (e) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Bad Request' })) + return + } + if (!serverId || !sourceUrl) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 server 或音频 URL' })) + return + } + const server = openlist.getServer(serverId) + if (!server) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '服务器不存在' })) + return + } + const tmpDir = path.join(global.lx.dataPath, 'tmp') + checkAndCreateDir(tmpDir) + openlist.uploadFromUrl(server, sourceUrl, fileName, dirPath, tmpDir).then((result) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, result })) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + }) + return + } + // [新增] 音乐搜索 API if (pathname === '/api/music/search' && req.method === 'GET') { const name = urlObj.searchParams.get('name') || '' @@ -6305,6 +7218,15 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro // } export const startServer = async (port: number, ip: string) => { + // 初始化卡密与阿里云盘 + try { + cards.initCards() + alidrive.loadConfig() + openlist.loadConfig() + console.log('[Server] Cards, Alidrive & OpenList modules initialized') + } catch (err: any) { + console.error('[Server] Failed to init cards/alidrive:', err.message) + } // Initialize file cache settings from global config if (global.lx.config) { if (global.lx.config.serverCacheLocation) fileCache.setCacheLocation(global.lx.config.serverCacheLocation) diff --git a/src/types/config.d.ts b/src/types/config.d.ts index b995eb37..9d33e3d8 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -170,6 +170,21 @@ declare namespace LX { */ 'player.password'?: string + /** + * 是否强制登录(未注册/未登录用户无法进入播放器) + */ + 'player.forceLogin'?: boolean + + /** + * 是否开放注册(允许使用卡密注册新账号) + */ + 'player.enableRegister'?: boolean + + /** + * 是否启用阿里云盘功能 + */ + 'player.enableAlidrive'?: boolean + /** * 是否启用针对所有外发请求的代理 (目前主要用于 Music SDK) */ From 65e5291aa5be4d1c6e3237ddd54dc63081ab21f2 Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Sun, 2 Aug 2026 07:07:58 +0000 Subject: [PATCH 02/39] fix: load OpenList/Alidrive on tab switch; add NAS deploy tooling - app.js: explicitly init/refresh AlidriveManager and OpenListManager in switchTab - Remove switchTab-overriding IIFEs from both manager scripts (they ran before app.js, dropping original tab logic) - docker-compose.yml: full deployment comments and env var examples (WEBPLAYER_PASSWORD/ENABLE_WEBPLAYER_AUTH) - scripts/migrate-to-nas.sh: package config.js + data/ into a tarball with NAS-side install steps --- docker-compose.yml | 46 ++++++++++++++-- public/music/app.js | 2 + public/music/js/aliyun_manager.js | 11 ---- public/music/js/openlist_manager.js | 10 ---- scripts/migrate-to-nas.sh | 85 +++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 25 deletions(-) create mode 100644 scripts/migrate-to-nas.sh diff --git a/docker-compose.yml b/docker-compose.yml index f3b03212..b6949fb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,13 @@ -version: '3.8' +# lxserver 同步服务器 - Docker Compose 配置(适用于飞牛 NAS / 群晖 / 任意 Docker 主机) +# 使用步骤: +# 1. 将本项目代码放到 NAS 固定目录, 如 /vol1/docker/lxserver +# 2. 将迁移生成的 config.js 与 data/ 目录放至同一目录(可省略, 首次启动会自动生成默认配置) +# 3. docker compose up -d --build +# 4. 浏览器访问 http://:9527/ (后台) 与 http://:9527/music/ (播放器) +# +# 注意: +# - 构建耗时较长(需编译 TS 并下载 fpcalc), 首次启动约 5-15 分钟 +# - 所有数据(用户/OpenList/卡密/云盘配置/缓存)持久化在 ./data 目录, 备份只需复制该目录 services: lx-sync-server: @@ -6,12 +15,41 @@ services: container_name: lx-sync-server restart: always ports: + # 端口映射: 宿主机端口:容器端口. 若 9527 被占用, 改左侧为其他端口即可, 例如 "9000:9527" - "9527:9527" volumes: - # 数据持久化 + # 数据持久化: 映射到容器内 /server/data (Dockerfile 已设置 DATA_PATH=/server/data) - ./data:/server/data environment: - # 环境变量配置,优先级高于 config.js + # 环境变量配置, 优先级高于 config.js - NODE_ENV=production - # 示例:通过环境变量设置用户 + + # ===== 可选配置(按需取消注释) ===== + + # 服务名称(显示在客户端) + # - SERVER_NAME=My Sync Server + + # 监听端口(默认 9527, 一般无需修改) + # - PORT=9527 + + # 管理员后台访问密码(默认见 config.js 的 frontend.password) + # - FRONTEND_PASSWORD=你的后台密码 + + # 播放器访问密码与是否强制登录 + # - ENABLE_WEBPLAYER_AUTH=true + # - WEBPLAYER_PASSWORD=123456 + + # 用户管理: 追加/覆盖用户账号, 格式 LX_USER_<用户名>=密码 + # 复杂配置可使用 JSON: LX_USER_user1='{ "password": "123.456", "maxSnapshotNum": 10 }' # - LX_USER_myuser=mypassword + + # 同步与备份间隔(分钟) + # - SYNC_INTERVAL=60 + + # 是否启用 WEBDAV 同步(留空则关闭) + # - WEBDAV_URL= + # - WEBDAV_USERNAME= + # - WEBDAV_PASSWORD= + + # 自定义配置文件路径(默认读取镜像内 /server/config.js) + # - CONFIG_PATH=/server/config.js diff --git a/public/music/app.js b/public/music/app.js index 8ee67a09..38608777 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -1100,10 +1100,12 @@ function switchTab(tabId) { if (tabId === 'alidrive') { document.getElementById('page-title').innerText = "阿里云盘"; + if (window.AliyunManager) window.AliyunManager.refresh(); } if (tabId === 'openlist') { document.getElementById('page-title').innerText = "OpenList"; + if (window.OpenListManager) window.OpenListManager.init(); } // Collapse Favorites if leaving diff --git a/public/music/js/aliyun_manager.js b/public/music/js/aliyun_manager.js index 2a7a94af..8e16e429 100644 --- a/public/music/js/aliyun_manager.js +++ b/public/music/js/aliyun_manager.js @@ -389,14 +389,3 @@ window.AliyunManager = { } }, }; - -// 切换 Tab 到阿里云盘时刷新 -(function () { - const origSwitchTab = window.switchTab; - window.switchTab = function (tabId) { - if (typeof origSwitchTab === 'function') origSwitchTab(tabId); - if (tabId === 'alidrive') { - window.AliyunManager.refresh(); - } - }; -})(); diff --git a/public/music/js/openlist_manager.js b/public/music/js/openlist_manager.js index 8b7c5125..e59fd62b 100644 --- a/public/music/js/openlist_manager.js +++ b/public/music/js/openlist_manager.js @@ -417,13 +417,3 @@ window.OpenListManager = { }, }; -// 切换 Tab 到 OpenList 时加载 -(function () { - const origSwitchTab = window.switchTab; - window.switchTab = function (tabId) { - if (typeof origSwitchTab === 'function') origSwitchTab(tabId); - if (tabId === 'openlist') { - window.OpenListManager.init(); - } - }; -})(); diff --git a/scripts/migrate-to-nas.sh b/scripts/migrate-to-nas.sh new file mode 100644 index 00000000..96a3a8f4 --- /dev/null +++ b/scripts/migrate-to-nas.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# lxserver 迁移到飞牛 NAS 辅助脚本 +# 用法: bash scripts/migrate-to-nas.sh [输出目录] +# 默认输出到 /tmp/opencode/nas-deploy/ +set -e + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OUT_DIR="${1:-/tmp/opencode/nas-deploy}" +TARBALL="${OUT_DIR}/lxserver-nas-deploy.tar.gz" + +if [ ! -d "${PROJECT_DIR}/data" ]; then + echo "错误: 未找到 ${PROJECT_DIR}/data 目录" + exit 1 +fi + +mkdir -p "${OUT_DIR}" + +# 1. 生成清洗后的 config.js(移除 users 的本地绝对 dataPath,NAS 上由系统重建) +echo "[1/3] 生成 NAS 用 config.js ..." +python3 - "${PROJECT_DIR}/config.js" "${OUT_DIR}/config.js" <<'PY' +import json, re, sys + +src, dst = sys.argv[1], sys.argv[2] +with open(src, 'r', encoding='utf-8') as f: + raw = f.read() + +# config.js 是 module.exports = {...},提取对象文本 +m = re.search(r'module\.exports\s*=\s*(\{.*\})\s*$', raw, re.S) +if not m: + # 兜底:直接复制 + with open(dst, 'w', encoding='utf-8') as f: + f.write(raw) + sys.exit(0) + +obj_text = m.group(1) +try: + obj = json.loads(obj_text) +except Exception: + # 含注释/单引号等 JSON5 风格,直接复制 + with open(dst, 'w', encoding='utf-8') as f: + f.write(raw) + sys.exit(0) + +# 清空每个用户的 dataPath,NAS 上由服务器按用户名重建 +for u in obj.get('users', []): + u['dataPath'] = '' + +with open(dst, 'w', encoding='utf-8') as f: + f.write('module.exports = ') + f.write(json.dumps(obj, ensure_ascii=False, indent=2)) + f.write('\n') +print(' config.js 已生成, 共', len(obj.get('users', [])), '个用户') +PY + +# 2. 复制整个 data 目录 +echo "[2/3] 打包 data/ 目录 ..." +STAGE="${OUT_DIR}/data" +rm -rf "${STAGE}" +mkdir -p "${STAGE}" +cp -a "${PROJECT_DIR}/data/." "${STAGE}/" + +# 3. 打包 +echo "[3/3] 生成部署包 ..." +cd "${OUT_DIR}" +tar -czf "${TARBALL}" config.js data +echo "" +echo "==== 部署包已生成: ${TARBALL} ====" +echo "" +echo "==== NAS 侧安装步骤 ====" +echo "1. 将 ${TARBALL} 上传到 NAS,并解压到项目目录:" +echo " mkdir -p /vol1/docker/lxserver && tar -xzf lxserver-nas-deploy.tar.gz -C /vol1/docker/lxserver" +echo "" +echo "2. 将项目代码(Dockerfile / docker-compose.yml / src / public 等)放到同一目录" +echo "" +echo "3. 修改 docker-compose.yml 映射端口后启动:" +echo " cd /vol1/docker/lxserver && docker compose up -d --build" +echo "" +echo "4. 访问:" +echo " 后台: http://:9527/" +echo " 播放器: http://:9527/music/" +echo "" +echo "==== 环境变量可选覆盖(docker-compose.yml 中配置,优先级高于 config.js)====" +echo " LX_USER_用户名=密码 # 追加/覆盖用户" +echo " WEBPLAYER_PASSWORD=密码 # 播放器访问密码" +echo " ENABLE_WEBPLAYER_AUTH=true # 开启播放器密码验证" From ecce15cf1afd0946e700a9365b0fe7f6c2f81692 Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Mon, 3 Aug 2026 02:52:06 +0000 Subject: [PATCH 03/39] =?UTF-8?q?fix:=20OpenList=20=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=8D=A1=E6=AD=BB=20-=20needle=20=E6=B5=81=E5=BC=8F=E4=BB=A3?= =?UTF-8?q?=E7=90=86=20bug=20=E4=BF=AE=E5=A4=8D=20+=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E8=BE=B9=E6=92=AD=E8=BE=B9=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stream 代理改用原生 http/https 替代 needle(needle 3.x 响应约 130KB 后卡死) - 新增边播边缓存:首次播放写入本地磁盘,后续播放/拖拽秒开 - 新增缓存接口:cache/check、cache/status、cache/clear(管理员) - 前端 OpenList 列表显示已缓存/缓存中徽标 - config.js 解除 git 跟踪并加入 .gitignore,防止真实凭据误提交 --- .gitignore | Bin 1220 -> 1338 bytes .monkeycode/MEMORY.md | 10 ++ config.js | 194 ---------------------------- public/music/js/openlist_manager.js | 36 ++++++ src/server/openlist.ts | 110 +++++++++++++++- src/server/server.ts | 149 +++++++++++++++++++++ 6 files changed, 302 insertions(+), 197 deletions(-) delete mode 100644 config.js diff --git a/.gitignore b/.gitignore index e430177af75973acc9d755d6dce3968d4b5876d3..475a5fac122cac631302f0c13854552e42a740ee 100644 GIT binary patch delta 126 zcmX@Yxr=MV5tcG8Wrb&R);yiF;bm*@^S$fd@9B6tVfEAYwa` - 强制登录开启时(player.forceLogin),播放器静态资源未登录会 302 到 `/music/login`;登录接口 `/api/user/login` 同时下发 `lx_player_session` 与 user token cookie - 卡密与阿里云盘配置分别持久化在 dataPath 下的 `cards.json`、`alidrive.json`,需配置 ClientID/ClientSecret 并在后台扫码绑定后才能使用云盘功能 + +[Project Knowledge Summary] +- Date: 2026-08-03 +- Context: Discovered by Agent while fixing OpenList 播放卡死问题 +- Category: Troubleshooting & Debugging +- Instructions: + - **needle 3.x 流式下载 bug**:`needle.get()` 在响应约 130KB(130896 字节)后会卡死不再输出数据,导致 `openlist.stream` 代理播放几秒就卡死。修复:改用 Node 原生 `http/https.request`(`src/server/openlist.ts` 的 `stream` 函数)。任何新的流式代理代码禁止使用 needle 转发大文件。 + - OpenList 播放已支持"边播边缓存":首次播放把数据同时写入 `/openlist-cache//.ext`,完整后 rename 落盘,之后播放/拖拽直接读本地(秒开)。缓存状态接口:`/api/openlist/cache/check`(单文件)、`/api/openlist/cache/status`(汇总)、`/api/openlist/cache/clear`(管理员)。前端 openlist_manager 会显示"已缓存/缓存中"徽标。 + - 真实 OpenList 上游速度实测约 360KB/s-1.3MB/s(此前 needle 卡死误判为上游限速 4KB/s),足够流畅播放。 + - config.js 含真实凭据,不进入 git 提交;NAS 部署用 `scripts/migrate-to-nas.sh` 生成清洗后的部署包。 diff --git a/config.js b/config.js deleted file mode 100644 index 2bad39c8..00000000 --- a/config.js +++ /dev/null @@ -1,194 +0,0 @@ -/** - * 配置文件 - * 配置优先级:WEBDAV备份数据 > 环境变量 > config.js (本文件) > src/defaultConfig.ts (默认配置) - */ -module.exports = { - // 同步服务名称 - // 环境变量: SERVER_NAME - "serverName": "lxserver", - - // 是否使用代理转发请求到本服务器 (如果配置了 proxy.header,此项会自动设为 true) - // 环境变量: 无 (通过 PROXY_HEADER 隐式开启) - "proxy.enabled": false, - - // 代理转发的请求头 原始IP - // 环境变量: PROXY_HEADER - "proxy.header": "x-real-ip", - - // 服务绑定IP (0.0.0.0 允许外网访问,127.0.0.1 仅限本机) - // 环境变量: BIND_IP - "bindIP": "0.0.0.0", - - // 服务监听端口 - // 环境变量: PORT - "port": 9527, - - // 是否开启用户路径 (baseurl/用户名) - // 开启后连接URL需包含用户名,允许不同用户使用相同密码。关闭后仅使用密码鉴权,要求所有用户密码唯一。 - // 环境变量: USER_ENABLE_PATH (true/false) - "user.enablePath": true, - - // 是否开启根路径 (baseurl) - // 开启后连接URL即为根路径,不允许不同用户使用相同密码。 - // 环境变量: USER_ENABLE_ROOT (true/false) - "user.enableRoot": false, - - // 是否启用公开用户权限限制 (开启后将限制公开用户的某些敏感操作,如上传、删除自定义源) - // 环境变量: ENABLE_PUBLIC_USER_RESTRICTION (true/false) - "user.enablePublicRestriction": true, - - // 是否开启公开收藏和歌曲 (开启后允许公开/未登录用户查看及播放公开收藏列表) - // 环境变量: ENABLE_PUBLIC_FAVORITES (true/false) - "user.enablePublicFavorites": false, - - // 是否开启非管理员访问本地音乐 (开启后允许未登录管理员的公开账号访问本地音乐) - // 环境变量: ENABLE_PUBLIC_NON_ADMIN_LOCAL_MUSIC (true/false) - "user.enablePublicNonAdminLocalMusic": false, - - // 是否开启非管理员访问公开收藏和歌曲 (开启后允许未登录管理员的公开账号查看公开收藏和歌曲) - // 环境变量: ENABLE_PUBLIC_NON_ADMIN_ACCESS (true/false) - "user.enablePublicNonAdminAccess": false, - - // 是否启用登录用户缓存限制 (开启后将限制非管理员登录用户的核心缓存设置) - // 环境变量: ENABLE_LOGIN_USER_CACHE_RESTRICTION (true/false) - "user.enableLoginCacheRestriction": false, - - // 是否启用缓存空间限制 (开启后超出容量将按 LRU 自动清理) - // 环境变量: ENABLE_CACHE_SIZE_LIMIT (true/false) - "user.enableCacheSizeLimit": false, - - // 缓存空间限制大小 (单位: MB) - // 环境变量: CACHE_SIZE_LIMIT - "user.cacheSizeLimit": 2000, - - // 最大快照数 (用于数据回滚) - // 环境变量: MAX_SNAPSHOT_NUM - "maxSnapshotNum": 10, - - // 添加歌曲到列表时的位置 (top: 顶部, bottom: 底部) - // 环境变量: LIST_ADD_MUSIC_LOCATION_TYPE - "list.addMusicLocationType": "top", - - // 是否禁用数据收集 - // 环境变量: DISABLE_TELEMETRY (true/false) - // 说明:仅收集版本号、运行环境(Docker/Node)、OS类型等非敏感信息用于项目改进。绝对匿名,不收集IP。 - "disableTelemetry": false, - - // 前端管理控制台访问密码 - // 环境变量: FRONTEND_PASSWORD - "frontend.password": "123456", - - // 用户列表 - // 环境变量: LX_USER_<用户名>=<密码> (例如: LX_USER_user1=123456) - "users": [ - { - "name": "admin", - "password": "password" - } - ], - - // WebDAV 同步配置 (可选,用于数据备份) - // 是否启用 WebDAV 同步与备份 - // 环境变量: WEBDAV_ENABLE (true/false) - "webdav.enable": false, - - // WebDAV 服务地址 - // 环境变量: WEBDAV_URL - "webdav.url": "", - - // WebDAV 用户名 - // 环境变量: WEBDAV_USERNAME - "webdav.username": "", - - // WebDAV 密码 - // 环境变量: WEBDAV_PASSWORD - "webdav.password": "", - - // WebDAV 增量同步远端路径 - // 环境变量: WEBDAV_SYNC_PATH - "webdav.syncPath": "/lx-sync", - - // WebDAV 全量备份远端路径 - // 环境变量: WEBDAV_BACKUP_PATH - "webdav.backupPath": "/lx-sync-backups", - - // 同步检测间隔 (分钟) - // 环境变量: SYNC_INTERVAL - "sync.interval": 60, - - // 全量备份间隔 (小时) - // 环境变量: BACKUP_INTERVAL - "sync.backupInterval": 24, - - // 是否启用 Web播放器 访问密码 - // 环境变量: ENABLE_WEBPLAYER_AUTH (true/false) - "player.enableAuth": false, - - // Web播放器 访问密码 - // 环境变量: WEBPLAYER_PASSWORD - "player.password": "123456", - - // 是否启用针对所有外发的请求代理 (目前主要用于离线音源的播放链接获取) - // 环境变量: PROXY_ALL_ENABLED (true/false) - "proxy.all.enabled": false, - - // 代理地址 (支持 http:// 或 socks5://) - // 环境变量: PROXY_ALL_ADDRESS (例如: http://127.0.0.1:7890) - "proxy.all.address": "", - - // 后台管理界面访问路径(默认为空,即根路径 /) - // 环境变量: ADMIN_PATH - "admin.path": "", - - // Web播放器访问路径(默认为 /music) - // 环境变量: PLAYER_PATH - "player.path": "/music", - - // Subsonic 协议配置 - // 是否启用 Subsonic 协议支持 (服务默认开启) - // 环境变量: SUBSONIC_ENABLE - "subsonic.enable": true, - - // Subsonic 访问路径 (默认为 /rest) - // 环境变量: SUBSONIC_PATH - "subsonic.path": "/rest", - - // 是否开启 Subsonic 调试日志模式 - // 环境变量: 无 - "subsonic.enableDebug": true, - - // 是否开启 Subsonic 在线全网搜索 - // 环境变量: 无 - "subsonic.onlineSearch": true, - - // Subsonic 在线搜索模式 (fallback: 回退模式, merge: 合并模式, local_only: 仅本地) - // 环境变量: 无 - "subsonic.onlineSearchMode": "fallback", - - // Subsonic 在线搜索默认平台 - // 环境变量: 无 - "subsonic.onlineSearchSources": "wy,tx,kw,kg,mg", - - // 是否在 Subsonic 歌词中包含翻译 - // 环境变量: 无 - "subsonic.lyricTranslation": true, - - // 歌手信息源优先级 (多个源用逗号分隔,如 tx,wy) - // 环境变量: SINGER_SOURCE_PRIORITY - "singer.sourcePriority": [ - "tx", - "wy" - ], - - // 歌手歌曲最大抓取页数 - // 环境变量: 无 - "artist.maxFetchPages": 20, - - // 缓存文件命名规则 (simple / custom) - // 环境变量: 无 - "cache.namingPattern": "simple", - - // 是否允许运行 VM 模式自定义源脚本 (默认关闭) - // 环境变量: 无 - "system.allowUnsafeVM": false -} \ No newline at end of file diff --git a/public/music/js/openlist_manager.js b/public/music/js/openlist_manager.js index e59fd62b..27a63e0e 100644 --- a/public/music/js/openlist_manager.js +++ b/public/music/js/openlist_manager.js @@ -13,6 +13,7 @@ window.OpenListManager = { searchMode: false, searchKeyword: '', loading: false, + _cacheBadges: {}, escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, ch => ({ @@ -141,6 +142,7 @@ window.OpenListManager = { async loadList(reset = true) { if (this.loading) return; this.loading = true; + if (reset) this._cacheBadges = {}; const statusEl = document.getElementById('ol-status'); const listEl = document.getElementById('ol-file-list'); if (!statusEl || !listEl || !this.currentServer) { @@ -226,11 +228,13 @@ window.OpenListManager = { audios.forEach((it, i) => { const globalIndex = i; + const cacheBadge = this._cacheBadges[this._fullPath(it.name)] || ''; html += `
    ${this.escapeHtml(it.name)} + ${cacheBadge} ${this.formatSize(it.size)}
    @@ -1197,6 +1205,7 @@

    本地 +
    @@ -1330,6 +1339,40 @@

    本地

    + +
    + + +
    +
    diff --git a/public/music/js/local_music.js b/public/music/js/local_music.js index dbe69083..ca845318 100644 --- a/public/music/js/local_music.js +++ b/public/music/js/local_music.js @@ -46,6 +46,14 @@ window.LocalMusicManager = { authExpired: false, authExpiredNotified: false, coverRenderTimer: null, + // [新增] 内嵌 OpenList 目录树浏览面板 + olServers: [], + olCurrentServerId: '', + olCurrentPath: '/', + olBreadcrumb: [], + olItems: [], + olSearchMode: false, + olPanelInitialized: false, escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, ch => ({ @@ -614,6 +622,7 @@ window.LocalMusicManager = { if (document.getElementById('lm-folder-select')) { document.getElementById('lm-folder-select').value = this.filterFolder; this._syncSelectActive('lm-folder-select'); + this.syncDirPlaylistBtn(); } // 标签按钮 UI 更新 this._syncTagUI('lm-quality-tags', this.filterQuality); @@ -723,6 +732,7 @@ window.LocalMusicManager = { this.resetFilters(false); this.bindListEvents(); this.syncPublicSongsBtn(); + this.syncDirPlaylistBtn(); this.fetchData(); this.syncRemasterVisibility(); @@ -774,9 +784,305 @@ window.LocalMusicManager = { changeFolder() { const el = document.getElementById('lm-folder-select'); this.filterFolder = el.value; + this.syncDirPlaylistBtn(); this.applyFilters(); }, + // 控制"目录加歌单"按钮显示:仅 OpenList / 下载 目录下可用 + syncDirPlaylistBtn() { + const btn = document.getElementById('lm-add-dir-playlist-btn'); + if (!btn) return; + const show = this.filterFolder === 'openlist' || this.filterFolder === 'music'; + btn.classList.toggle('hidden', !show); + }, + + // ===== 内嵌 OpenList 目录树浏览面板 ===== + toggleOpenListPanel() { + const body = document.getElementById('lm-ol-panel-body'); + const arrow = document.getElementById('lm-ol-panel-arrow'); + if (!body) return; + const open = body.classList.contains('hidden'); + body.classList.toggle('hidden', !open); + if (arrow) arrow.className = open ? 'fas fa-chevron-up text-[10px] t-text-muted' : 'fas fa-chevron-down text-[10px] t-text-muted'; + if (open && !this.olPanelInitialized) { + this.olPanelInitialized = true; + this.loadOlServers(); + } + }, + + async loadOlServers() { + const headers = {}; + if (window.getUserAuthHeaders) Object.assign(headers, window.getUserAuthHeaders()); + try { + const res = await fetch('/api/openlist/available', { headers }); + if (!res.ok) throw new Error('加载失败'); + const data = await res.json(); + this.olServers = (data.servers || []).filter(s => s.baseUrl); + const select = document.getElementById('lm-ol-server-select'); + if (!select) return; + const saved = localStorage.getItem('lx_openlist_server'); + let options = ''; + this.olServers.forEach(s => { + options += ``; + }); + select.innerHTML = options; + if (saved && this.olServers.some(s => s.id === saved)) { + select.value = saved; + this.selectOlServer(saved); + } + } catch (err) { + const statusEl = document.getElementById('lm-ol-status'); + if (statusEl) statusEl.textContent = '加载服务器失败: ' + err.message; + } + }, + + async selectOlServer(serverId) { + this.olCurrentServerId = serverId; + const server = this.olServers.find(s => s.id === serverId) || null; + if (serverId) localStorage.setItem('lx_openlist_server', serverId); + if (!server) { + const statusEl = document.getElementById('lm-ol-status'); + if (statusEl) statusEl.textContent = '请先选择 OpenList 服务器'; + const listEl = document.getElementById('lm-ol-file-list'); + if (listEl) listEl.innerHTML = ''; + const crumbEl = document.getElementById('lm-ol-breadcrumb'); + if (crumbEl) crumbEl.innerHTML = ''; + return; + } + await this.refreshOpenList(); + }, + + async refreshOpenList() { + this.olSearchMode = false; + const server = this.olServers.find(s => s.id === this.olCurrentServerId) || null; + this.olCurrentPath = server ? (server.rootPath || '/') : '/'; + this.olBreadcrumb = [{ path: this.olCurrentPath, name: '根目录' }]; + this.renderOlBreadcrumb(); + await this.loadOlList(true); + }, + + async loadOlList(reset = true) { + if (!this.olCurrentServerId) return; + const statusEl = document.getElementById('lm-ol-status'); + const listEl = document.getElementById('lm-ol-file-list'); + if (!statusEl || !listEl) return; + if (reset) listEl.innerHTML = '
    正在加载...
    '; + + const headers = {}; + if (window.getUserAuthHeaders) Object.assign(headers, window.getUserAuthHeaders()); + + let url; + if (this.olSearchMode) { + url = `/api/openlist/search?server=${encodeURIComponent(this.olCurrentServerId)}&keyword=${encodeURIComponent(this.olSearchKeyword || '')}`; + } else { + url = `/api/openlist/list?server=${encodeURIComponent(this.olCurrentServerId)}&path=${encodeURIComponent(this.olCurrentPath)}`; + } + + try { + const res = await fetch(url, { headers }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || '加载失败'); + } + const data = await res.json(); + if (!data.success) throw new Error(data.message || '加载失败'); + this.olItems = data.items || []; + this.renderOlList(reset); + } catch (err) { + statusEl.textContent = '加载失败: ' + err.message; + if (reset) listEl.innerHTML = ''; + } + }, + + renderOlBreadcrumb() { + const crumbEl = document.getElementById('lm-ol-breadcrumb'); + if (!crumbEl) return; + let html = ''; + this.olBreadcrumb.forEach((item, i) => { + if (i === this.olBreadcrumb.length - 1) { + html += `${this.escapeHtml(item.name)}`; + } else { + html += ` + + + `; + } + }); + crumbEl.innerHTML = html; + }, + + olGoTo(index) { + this.olBreadcrumb = this.olBreadcrumb.slice(0, index + 1); + const target = this.olBreadcrumb[this.olBreadcrumb.length - 1]; + this.olSearchMode = false; + this.olCurrentPath = target.path; + this.renderOlBreadcrumb(); + this.loadOlList(true); + }, + + olNavigateTo(dirPath, name) { + this.olSearchMode = false; + this.olCurrentPath = dirPath; + this.olBreadcrumb.push({ path: dirPath, name }); + this.renderOlBreadcrumb(); + this.loadOlList(true); + }, + + olGoBack() { + if (this.olBreadcrumb.length > 1) { + this.olBreadcrumb.pop(); + const prev = this.olBreadcrumb[this.olBreadcrumb.length - 1]; + this.olSearchMode = false; + this.olCurrentPath = prev.path; + this.renderOlBreadcrumb(); + this.loadOlList(true); + } + }, + + renderOlList(reset) { + const statusEl = document.getElementById('lm-ol-status'); + const listEl = document.getElementById('lm-ol-file-list'); + if (!statusEl || !listEl) return; + + const folders = this.olItems.filter(it => it.is_dir); + const audios = this.olItems.filter(it => !it.is_dir && /\.(mp3|flac|wav|ogg|aac|m4a|ape|wma|opus|alac)$/i.test(it.name)); + const lyricFiles = this.olItems.filter(it => !it.is_dir && /\.(lrc|lrcx)$/i.test(it.name)); + const otherCount = this.olItems.length - folders.length - audios.length - lyricFiles.length; + + if (!this.olItems.length) { + statusEl.textContent = '此目录为空'; + if (reset) listEl.innerHTML = '
    此目录为空
    '; + return; + } + statusEl.textContent = `共 ${this.olItems.length} 项(音频 ${audios.length})`; + + let html = ''; + if (reset && this.olBreadcrumb.length > 1) { + html += ` +
    + + 返回上级 +
    `; + } + + folders.forEach(it => { + const childPath = (this.olCurrentPath === '/' ? '' : this.olCurrentPath) + '/' + it.name; + html += ` +
    + + ${this.escapeHtml(it.name)} +
    `; + }); + + audios.forEach((it, i) => { + const fullPath = (this.olCurrentPath === '/' ? '' : this.olCurrentPath) + '/' + it.name; + const sign = it.sign || ''; + html += ` +
    + + ${this.escapeHtml(it.name)} + ${this.escapeHtml(this.formatOlSize(it.size))} + +
    `; + }); + + if (lyricFiles.length) { + html += `
    歌词文件(随歌曲自动识别)
    `; + } + if (otherCount > 0) { + html += `
    其他文件 ${otherCount} 个(已隐藏)
    `; + } + + if (reset) { + listEl.innerHTML = html; + } else { + listEl.insertAdjacentHTML('beforeend', html); + } + }, + + formatOlSize(size) { + if (!size) return ''; + if (size < 1024) return size + ' B'; + if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'; + if (size < 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(1) + ' MB'; + return (size / 1024 / 1024 / 1024).toFixed(2) + ' GB'; + }, + + olBuildSong(file) { + const name = file.name.replace(/\.[^.]+$/, ''); + const fullPath = (this.olCurrentPath === '/' ? '' : this.olCurrentPath) + '/' + file.name; + const sign = file.sign || ''; + const username = (window.currentListData && window.currentListData.username) || localStorage.getItem('lx_sync_user') || '_open'; + const authToken = (window.getUserAuthHeaders ? window.getUserAuthHeaders()['x-user-token'] : null) || localStorage.getItem('lx_user_token') || ''; + const tokenSuffix = authToken ? `&token=${encodeURIComponent(authToken)}` : ''; + return { + id: `openlist_${encodeURIComponent(fullPath)}`, + songmid: `openlist_${encodeURIComponent(fullPath)}`, + songId: `openlist_${encodeURIComponent(fullPath)}`, + source: 'openlist', + name, + singer: '', + path: fullPath, + serverId: this.olCurrentServerId, + sign, + url: `/api/openlist/stream?server=${encodeURIComponent(this.olCurrentServerId)}&path=${encodeURIComponent(fullPath)}${sign ? `&sign=${encodeURIComponent(sign)}` : ''}${tokenSuffix}`, + isLocal: true, + openlist: true, + folder: 'openlist', + quality: 'flac', + type: 'flac', + interval: 0 + }; + }, + + olPlayAudio(fileName, audioIndex = 0) { + const audios = this.olItems.filter(it => !it.is_dir && /\.(mp3|flac|wav|ogg|aac|m4a|ape|wma|opus|alac)$/i.test(it.name)); + if (!audios.length) return; + const playlist = audios.map(f => this.olBuildSong(f)); + const idx = Math.max(audios.findIndex(a => a.name === fileName), 0); + if (typeof window.updatePlaylist === 'function') { + window.updatePlaylist(playlist, idx, 'openlist'); + } else if (typeof window.playSong === 'function') { + window.playSong(playlist[idx], idx); + } + }, + + olAddSongToPlaylist(fileName) { + const audios = this.olItems.filter(it => !it.is_dir && /\.(mp3|flac|wav|ogg|aac|m4a|ape|wma|opus|alac)$/i.test(it.name)); + const target = audios.find(a => a.name === fileName); + if (!target) return; + const song = this.olBuildSong(target); + if (typeof window.openPlaylistAddModal !== 'function') { + if (typeof showError === 'function') showError('歌单组件尚未加载完成'); + return; + } + window.openPlaylistAddModal([song]); + }, + + // 将内嵌面板当前浏览目录下的所有音频保存为歌单 + async olAddCurrentDirToPlaylist() { + const audios = this.olItems.filter(it => !it.is_dir && /\.(mp3|flac|wav|ogg|aac|m4a|ape|wma|opus|alac)$/i.test(it.name)); + if (!audios.length) { + if (typeof showError === 'function') showError('当前目录没有音频文件'); + return; + } + if (typeof window.openPlaylistAddModal !== 'function') { + if (typeof showError === 'function') showError('歌单组件尚未加载完成'); + return; + } + const dirLabel = this.olCurrentPath || '/'; + if (typeof showInfo === 'function') showInfo(`正在将目录「${dirLabel}」下的 ${audios.length} 首歌曲加入歌单...`); + window.openPlaylistAddModal(audios.map(file => this.olBuildSong(file)).filter(Boolean)); + }, + toggleUnindexed() { const el = document.getElementById('lm-unindexed-filter'); this.filterUnindexed = el.checked; @@ -1067,7 +1373,7 @@ window.LocalMusicManager = { // 3.0.1 Update SubPath Button State const subPathBtn = document.getElementById('lm-subpath-btn'); if (subPathBtn) { - if (this.filterFolder !== 'music') { + if (this.filterFolder !== 'music' && this.filterFolder !== 'openlist') { if (this.selectedSubPath !== '') { this.selectedSubPath = ''; const subPathText = document.getElementById('lm-subpath-text'); @@ -1299,7 +1605,11 @@ window.LocalMusicManager = { return d.toLocaleDateString() + ' ' + d.toLocaleTimeString().slice(0, 5); }; - const folderIcon = item.folder === 'music' ? '' : ''; + const folderIcon = item.folder === 'music' + ? '' + : (item.folder === 'openlist' || item.openlist) + ? '' + : ''; html += `
    @@ -1585,12 +1895,16 @@ window.LocalMusicManager = { }, isPlaylistCollectable(item) { + if (item.folder === 'openlist' || item.openlist || (item.songInfo && item.songInfo.source === 'openlist')) return true; return !!this.getPlaylistPlatformIdentity(item); }, buildPlaylistSong(item) { const songInfo = item?.songInfo || {}; - const identity = this.getPlaylistPlatformIdentity(item); + const isOpenList = item.folder === 'openlist' || item.openlist || songInfo.source === 'openlist'; + const identity = isOpenList + ? { source: 'openlist', platformId: String(item?.path || item?.filename || ''), id: `openlist_${encodeURIComponent(item?.path || item?.filename || '')}` } + : this.getPlaylistPlatformIdentity(item); if (!identity) return null; const quality = item?.quality || songInfo.quality || songInfo.type || '128k'; let types = songInfo.types; @@ -1605,7 +1919,7 @@ window.LocalMusicManager = { if (!types[quality]) types[quality] = { size: item?.size || 0 }; } - return { + const result = { ...songInfo, id: identity.id, songmid: identity.platformId, @@ -1622,6 +1936,17 @@ window.LocalMusicManager = { types, _localLibraryItem: true }; + // 保留 OpenList 播放所需字段(收藏到歌单后仍可恢复播放) + if (isOpenList) { + result.url = item?.url || songInfo.url || ''; + result.serverId = item?.serverId || songInfo.serverId || ''; + result.path = item?.path || item?.filename || ''; + result.sign = item?.sign || songInfo.sign || ''; + result.openlist = true; + result.isLocal = true; + result.folder = 'openlist'; + } + return result; }, batchAddToPlaylist() { @@ -1652,6 +1977,52 @@ window.LocalMusicManager = { window.openPlaylistAddModal(collectableTargets.map(item => this.buildPlaylistSong(item)).filter(Boolean)); }, + // 将当前目录(OpenList 目录或下载目录子路径)下的歌曲一键保存为歌单 + async addCurrentDirToPlaylist() { + const folder = this.filterFolder; + if (folder !== 'openlist' && folder !== 'music') { + if (typeof showInfo === 'function') showInfo('请先在筛选中选择“OpenList”或“下载”目录'); + return; + } + const targetSubPath = this.selectedSubPath; + const dirLabel = targetSubPath === '' ? (folder === 'openlist' ? 'OpenList 全部' : '下载根目录') + : targetSubPath === '__ROOT__' ? '根目录' + : targetSubPath; + + // 收集当前目录下的所有歌曲(含子目录?仅当前目录,与界面 subPath 筛选一致) + const targets = this.originalData.filter(item => { + if (folder === 'openlist') { + if (item.folder !== 'openlist' && !item.openlist) return false; + } else { + if (item.folder !== 'music') return false; + } + if (targetSubPath !== '') { + const target = targetSubPath === '__ROOT__' ? '' : targetSubPath; + if ((item.subPath || '') !== target) return false; + } + return true; + }); + + if (targets.length === 0) { + if (typeof showError === 'function') showError('当前目录没有歌曲'); + return; + } + + const collectable = targets.filter(item => this.isPlaylistCollectable(item)); + if (collectable.length === 0) { + if (typeof showError === 'function') showError('当前目录的歌曲无法收藏到歌单'); + return; + } + + if (typeof window.openPlaylistAddModal !== 'function') { + if (typeof showError === 'function') showError('歌单组件尚未加载完成'); + return; + } + + if (typeof showInfo === 'function') showInfo(`正在将目录「${dirLabel}」下的 ${collectable.length} 首歌曲加入歌单...`); + window.openPlaylistAddModal(collectable.map(item => this.buildPlaylistSong(item)).filter(Boolean)); + }, + playItem(index) { const item = this.displayData[index]; if (!item) return; @@ -1660,23 +2031,38 @@ window.LocalMusicManager = { const username = (window.currentListData && window.currentListData.username) || localStorage.getItem('lx_sync_user') || '_open'; const authToken = (window.getUserAuthHeaders ? window.getUserAuthHeaders()['x-user-token'] : null) || localStorage.getItem('lx_user_token') || ''; - // Important: Use existing checkCache via global logic if possible, - // or directly supply local URL + // OpenList 条目使用其 stream URL(含 server/path/sign),本地缓存条目使用 cache/file URL + const isOpenList = item.folder === 'openlist' || item.openlist; + const buildLocalUrl = (d) => { + if (isOpenList && d.openlist) { + return d.url + (authToken && d.url && !d.url.includes('token=') ? `${d.url.includes('?') ? '&' : '?'}token=${encodeURIComponent(authToken)}` : ''); + } + return `/api/music/cache/file/${encodeURIComponent(username)}/${encodeURIComponent(d.filename)}?folder=${d.folder}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`; + }; + const songInfo = { ...item.songInfo, // Reconstruct full URL locally - url: `/api/music/cache/file/${encodeURIComponent(username)}/${encodeURIComponent(item.filename)}?folder=${item.folder}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`, - pic: `/api/music/cache/cover?filename=${encodeURIComponent(item.filename)}&user=${encodeURIComponent(username)}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`, + url: buildLocalUrl(item), + pic: isOpenList ? '' : `/api/music/cache/cover?filename=${encodeURIComponent(item.filename)}&user=${encodeURIComponent(username)}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`, isLocal: true, folder: item.folder }; + // 保留 OpenList 播放所需的 serverId/path/sign 字段 + if (isOpenList) { + songInfo.serverId = item.serverId; + songInfo.path = item.path; + songInfo.sign = item.sign; + songInfo.openlist = true; + songInfo.source = 'openlist'; + } // If 'app.js' exposes playSong(song), we use it. // We might want to construct a playlist of local tracks. const playlist = this.displayData.map(d => ({ ...d.songInfo, - url: `/api/music/cache/file/${encodeURIComponent(username)}/${encodeURIComponent(d.filename)}?folder=${d.folder}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`, - pic: `/api/music/cache/cover?filename=${encodeURIComponent(d.filename)}&user=${encodeURIComponent(username)}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`, + url: buildLocalUrl(d), + pic: (d.folder === 'openlist' || d.openlist) ? '' : `/api/music/cache/cover?filename=${encodeURIComponent(d.filename)}&user=${encodeURIComponent(username)}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`, isLocal: true })); @@ -2030,7 +2416,17 @@ window.LocalMusicManager = { if (!item) return; const username = (window.currentListData && window.currentListData.username) || localStorage.getItem('lx_sync_user') || '_open'; const authToken = (window.getUserAuthHeaders ? window.getUserAuthHeaders()['x-user-token'] : null) || localStorage.getItem('lx_user_token') || ''; - const url = `/api/music/cache/file/${encodeURIComponent(username)}/${encodeURIComponent(item.filename)}?folder=${item.folder}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`; + + // OpenList 条目走 openlist/download 接口 + const isOpenList = item.folder === 'openlist' || item.openlist; + let url; + if (isOpenList) { + url = `/api/openlist/download?server=${encodeURIComponent(item.serverId || '')}&path=${encodeURIComponent(item.path || item.filename)}`; + if (item.sign) url += `&sign=${encodeURIComponent(item.sign)}`; + if (authToken) url += `&token=${encodeURIComponent(authToken)}`; + } else { + url = `/api/music/cache/file/${encodeURIComponent(username)}/${encodeURIComponent(item.filename)}?folder=${item.folder}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`; + } const a = document.createElement('a'); a.href = url; @@ -2567,8 +2963,8 @@ window.LocalMusicManager = { }, async openSubPathModal(mode = 'filter') { - if (this.filterFolder !== 'music') { - if (typeof showInfo === 'function') showInfo('请先在筛选中选择“下载”目录'); + if (this.filterFolder !== 'music' && this.filterFolder !== 'openlist') { + if (typeof showInfo === 'function') showInfo('请先在筛选中选择“下载”或“OpenList”目录'); return; } this.subPathModalMode = mode; @@ -2588,11 +2984,28 @@ window.LocalMusicManager = { }, 10); try { - const res = await fetch(`/api/music/cache/subdirs?folder=music`, { - headers: window.getUserAuthHeaders ? window.getUserAuthHeaders() : {} - }); - const { data } = await res.json(); - this.renderSubPathList(data || []); + let dirs = []; + if (this.filterFolder === 'openlist') { + // OpenList 目录从已加载的本地音乐索引中提取 subPath(服务器目录树) + const seen = new Set(); + this.originalData.forEach(item => { + if (item.folder !== 'openlist' && !item.openlist) return; + const sub = item.subPath || ''; + if (sub && !seen.has(sub)) { + seen.add(sub); + dirs.push(sub); + } + }); + // 根目录 + dirs.sort(); + } else { + const res = await fetch(`/api/music/cache/subdirs?folder=music`, { + headers: window.getUserAuthHeaders ? window.getUserAuthHeaders() : {} + }); + const { data } = await res.json(); + dirs = data || []; + } + this.renderSubPathList(dirs); } catch (e) { console.error('Failed to fetch subdirs:', e); if (typeof showError === 'function') showError('获取子目录失败'); diff --git a/src/defaultConfig.ts b/src/defaultConfig.ts index 1b9e2635..59aa6a70 100644 --- a/src/defaultConfig.ts +++ b/src/defaultConfig.ts @@ -14,6 +14,7 @@ const config: LX.Config = { 'user.enableLoginCacheRestriction': false, // 是否启用登录用户缓存限制 'user.enableCacheSizeLimit': false, // 是否启用缓存空间限制 'user.cacheSizeLimit': 2000, // 缓存空间限制大小 (MB) + 'user.enableOpenListInLocalMusic': true, // 是否将 OpenList 目录整合到本地音乐列表 maxSnapshotNum: 10, // 公共最大备份快照数 'list.addMusicLocationType': 'top', // 公共添加歌曲到我的列表时的位置 top | bottom,参考客户端的「设置 → 列表设置 → 添加歌曲到列表时的位置」 diff --git a/src/server/openlist.ts b/src/server/openlist.ts index d96a32a5..200672f1 100644 --- a/src/server/openlist.ts +++ b/src/server/openlist.ts @@ -364,6 +364,136 @@ export const isFileCached = (server: OpenListServer, filePath: string): boolean return fs.existsSync(getCacheFilePath(server, filePath)) } +// ===== OpenList 本地音乐整合:递归扫描目录树收集音频文件 ===== + +const AUDIO_EXT_RE = /\.(mp3|flac|wav|ogg|aac|m4a|ape|wma|opus|alac)$/i + +// 本地音乐索引缓存:serverId -> { files, at },避免每次请求都全量递归扫描远程目录树 +const localIndexCache: Record }> = {} +const LOCAL_INDEX_TTL = 120 * 1000 // 2 分钟 +const MAX_SCAN_DEPTH = 20 // 防止深层目录爆炸 +const MAX_SCAN_FILES = 5000 // 单服务器最多收集文件数,防止超大目录 +const MAX_SCAN_DIRS = 800 // 单服务器最多访问目录数,防止远程挂载网盘爆炸 +const MAX_SCAN_MS = 60 * 1000 // 单次扫描总时长上限 +const SCAN_CONCURRENCY = 6 // 目录列表并发数,控制远程压力 + +/** + * 带超时的 listFiles:避免 needle 对超大目录/远程网盘永久挂起 + */ +const listFilesWithTimeout = async (server: OpenListServer, dirPath: string, timeoutMs = 20000): Promise => { + return Promise.race([ + listFiles(server, dirPath, 1, 0), + new Promise((resolve) => setTimeout(() => resolve({ content: [], total: 0 }), timeoutMs)), + ]) +} + +/** + * 递归收集目录树下的所有音频文件(映射为本地音乐 CacheItem 兼容结构) + */ +const collectAudioFiles = async (server: OpenListServer, dirPath: string, depth = 0, result: any[] = [], ctx: { dirCount: number; deadline: number } = { dirCount: 0, deadline: Date.now() + MAX_SCAN_MS }): Promise => { + if (depth > MAX_SCAN_DEPTH || result.length >= MAX_SCAN_FILES || ctx.dirCount >= MAX_SCAN_DIRS || Date.now() > ctx.deadline) return result + let list: any + try { + list = await listFilesWithTimeout(server, dirPath) + } catch (e: any) { + return result + } + ctx.dirCount++ + const content: any[] = (list && list.content) || [] + const subDirs: string[] = [] + for (const it of content) { + if (result.length >= MAX_SCAN_FILES || ctx.dirCount >= MAX_SCAN_DIRS || Date.now() > ctx.deadline) break + if (it.is_dir) { + const childPath = (dirPath === '/' ? '' : dirPath) + '/' + it.name + subDirs.push(childPath) + continue + } + if (!it.name || !AUDIO_EXT_RE.test(it.name)) continue + const ext = (path.extname(it.name) || '.mp3').toLowerCase().slice(1) + const fullPath = (dirPath === '/' ? '' : dirPath) + '/' + it.name + const id = `openlist_${encodeURIComponent(fullPath)}` + const subPath = (dirPath === '/' ? '' : dirPath) + const modified = typeof it.modified === 'number' ? it.modified : Date.parse(String(it.modified || '')) || 0 + result.push({ + id, + songmid: id, + songId: id, + name: it.name.replace(/\.[^.]+$/, ''), + singer: '', + album: '', + albumId: '', + source: 'openlist', + downloadSource: 'openlist', + sourceName: server.name, + quality: ext === 'flac' ? 'flac' : ext, + filename: fullPath, + folder: 'openlist', + subPath, + mtime: modified || Date.now(), + size: it.size || 0, + ext, + hasCover: false, + coverType: 'none', + hasLyric: false, + serverId: server.id, + path: fullPath, + sign: it.sign || '', + isLocal: true, + openlist: true, + interval: 0, + url: `/api/openlist/stream?server=${encodeURIComponent(server.id)}&path=${encodeURIComponent(fullPath)}${it.sign ? `&sign=${encodeURIComponent(it.sign)}` : ''}`, + }) + } + // 并发遍历子目录(限流),避免远程挂载网盘造成的串行长耗时 + let idx = 0 + while (idx < subDirs.length) { + const batch = subDirs.slice(idx, idx + SCAN_CONCURRENCY) + idx += SCAN_CONCURRENCY + await Promise.all(batch.map(dir => collectAudioFiles(server, dir, depth + 1, result, ctx))) + if (Date.now() > ctx.deadline || ctx.dirCount >= MAX_SCAN_DIRS || result.length >= MAX_SCAN_FILES) break + } + return result +} + +/** + * 获取某服务器的本地音乐索引(带缓存,forceRefresh 强制重新扫描) + */ +export const getLocalIndex = (serverId: string, forceRefresh = false): Promise => { + const server = getServer(serverId) + if (!server || !server.enabled || !server.baseUrl) return Promise.resolve([]) + const cached = localIndexCache[serverId] + if (!forceRefresh && cached && cached.files && Date.now() - cached.at < LOCAL_INDEX_TTL) { + return Promise.resolve(cached.files) + } + if (!forceRefresh && cached && cached.pending) { + return cached.pending + } + const pending = collectAudioFiles(server, server.rootPath || '/').then(files => { + localIndexCache[serverId] = { files, at: Date.now() } + return files + }) + if (!cached) localIndexCache[serverId] = { files: [], at: 0, pending } + else localIndexCache[serverId] = { ...cached, pending } + return pending +} + +/** + * 获取所有启用 OpenList 服务器的本地音乐索引(合并) + */ +export const getAllLocalIndex = (forceRefresh = false): Promise => { + const servers = listServers().filter(s => s.enabled && s.baseUrl) + return Promise.all(servers.map(s => getLocalIndex(s.id, forceRefresh))).then(groups => { + const merged: any[] = [] + groups.forEach(group => merged.push(...group)) + return merged + }) +} + +export const clearLocalIndex = (serverId?: string): void => { + if (serverId) delete localIndexCache[serverId] + else Object.keys(localIndexCache).forEach(k => delete localIndexCache[k]) +} + /** * 获取同目录歌词(path 形如 /dir/song.mp3,找 /dir/song.lrc) */ diff --git a/src/server/server.ts b/src/server/server.ts index 2574cd47..9f61a749 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -3149,6 +3149,18 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro username = verified } void fileCache.getCacheList(username).then(list => { + // [新增] 整合 OpenList:若服务器配置了 OpenList 服务器,把目录树扫描的音频文件合并进本地音乐列表 + const mergeOpenList = global.lx.config['user.enableOpenListInLocalMusic'] !== false + if (mergeOpenList) { + return openlist.getAllLocalIndex().then(openListFiles => { + const merged = openListFiles.length ? [...list, ...openListFiles] : list + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }) + res.end(JSON.stringify({ success: true, data: merged })) + }) + } res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate', @@ -4634,6 +4646,8 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro if (resp.headers['content-length']) outHeaders['Content-Length'] = resp.headers['content-length'] if (resp.headers['accept-ranges']) outHeaders['Accept-Ranges'] = resp.headers['accept-ranges'] if (resp.headers['content-range']) outHeaders['Content-Range'] = resp.headers['content-range'] + // [Fix] 上游直链可能 302 跳转到对象存储/CDN,必须透传 Location + if (resp.headers['location']) outHeaders['Location'] = String(resp.headers['location']) outHeaders['Cache-Control'] = 'no-cache' res.writeHead(statusCode, outHeaders) resp.pipe(res) @@ -5044,68 +5058,95 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro return } openlist.stream(server, filePath, sign, req.headers.range as string | undefined).then((proxyReq: any) => { - proxyReq.on('error', (err: any) => { + const httpMod = require('http') + const httpsMod = require('https') + const handleStreamError = (err: any) => { if (!res.headersSent) { res.writeHead(502, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ success: false, message: err.message })) } else { res.end() } - }) - proxyReq.on('response', (resp: any) => { - const statusCode = resp.statusCode || 200 - const outHeaders: Record = {} - if (resp.headers['content-type']) outHeaders['Content-Type'] = String(resp.headers['content-type']).split(';')[0] || 'audio/mpeg' - if (resp.headers['content-length']) outHeaders['Content-Length'] = resp.headers['content-length'] - if (resp.headers['accept-ranges']) outHeaders['Accept-Ranges'] = resp.headers['accept-ranges'] - if (resp.headers['content-range']) outHeaders['Content-Range'] = resp.headers['content-range'] - outHeaders['Cache-Control'] = 'no-cache' - res.writeHead(statusCode, outHeaders) - - // [边播边缓存] 仅当请求从头开始(无 Range 或 bytes=0-)时缓存,避免缓存部分分片 - const rangeHeader = String(req.headers.range || '') - const isFullRange = !rangeHeader || rangeHeader === 'bytes=0-' || rangeHeader === 'bytes=0' - if (isFullRange) { - const tmpPath = cacheFilePath + '.tmp' - const cacheWs = fs.createWriteStream(tmpPath, { flags: 'w' }) - let cacheReceived = 0 - const total = parseInt(resp.headers['content-length'] || '0', 10) - openlist.trackCacheProgress(server.id, filePath, total, 0) - resp.on('data', (chunk: any) => { - cacheReceived += chunk.length - cacheWs.write(chunk) - openlist.trackCacheProgress(server.id, filePath, total, cacheReceived) - }) - resp.on('end', () => { - cacheWs.end(() => { - // 下载完整则正式落盘,否则丢弃临时文件 - if (total === 0 || cacheReceived >= total) { - fs.rename(tmpPath, cacheFilePath, (err: any) => { - if (err) fs.unlink(tmpPath, () => { }) - openlist.markCacheDone(server.id, filePath) - }) - } else { - fs.unlink(tmpPath, () => { }) - openlist.clearCacheProgress(server.id, filePath) - } + } + // 递归跟随 3xx 重定向(上游 /d/ 直链会 302 到对象存储/CDN),最多 5 跳 + const followRedirect = (currentReq: any, hop = 0) => { + currentReq.on('error', handleStreamError) + currentReq.on('response', (resp: any) => { + const statusCode = resp.statusCode || 200 + const location = resp.headers['location'] + if (hop < 5 && statusCode >= 300 && statusCode < 400 && location) { + resp.resume() + let targetUrl: URL + try { + targetUrl = new URL(location) + } catch (e) { + handleStreamError(new Error('非法重定向地址')) + return + } + const lib = targetUrl.protocol === 'https:' ? httpsMod : httpMod + const redirectHeaders: Record = {} + const rangeHeader = String(req.headers.range || '') + if (rangeHeader) redirectHeaders['Range'] = rangeHeader + const nextReq = lib.request(targetUrl, { method: 'GET', headers: redirectHeaders } as any) + nextReq.on('error', () => { /* 下一跳处理 */ }) + nextReq.end() + followRedirect(nextReq, hop + 1) + return + } + const outHeaders: Record = {} + if (resp.headers['content-type']) outHeaders['Content-Type'] = String(resp.headers['content-type']).split(';')[0] || 'audio/mpeg' + if (resp.headers['content-length']) outHeaders['Content-Length'] = resp.headers['content-length'] + if (resp.headers['accept-ranges']) outHeaders['Accept-Ranges'] = resp.headers['accept-ranges'] + if (resp.headers['content-range']) outHeaders['Content-Range'] = resp.headers['content-range'] + outHeaders['Cache-Control'] = 'no-cache' + res.writeHead(statusCode, outHeaders) + + // [边播边缓存] 仅当请求从头开始(无 Range 或 bytes=0-)时缓存,避免缓存部分分片 + const rangeHeader = String(req.headers.range || '') + const isFullRange = !rangeHeader || rangeHeader === 'bytes=0-' || rangeHeader === 'bytes=0' + if (isFullRange) { + const tmpPath = cacheFilePath + '.tmp' + const cacheWs = fs.createWriteStream(tmpPath, { flags: 'w' }) + let cacheReceived = 0 + const total = parseInt(resp.headers['content-length'] || '0', 10) + openlist.trackCacheProgress(server.id, filePath, total, 0) + resp.on('data', (chunk: any) => { + cacheReceived += chunk.length + cacheWs.write(chunk) + openlist.trackCacheProgress(server.id, filePath, total, cacheReceived) }) - }) - resp.on('error', () => { - cacheWs.destroy() - fs.unlink(tmpPath, () => { }) - openlist.clearCacheProgress(server.id, filePath) - }) - res.on('close', () => { - // 客户端中断:停止缓存写入并清理临时文件(下次播放重新缓存) - if (!resp.complete) { + resp.on('end', () => { + cacheWs.end(() => { + // 下载完整则正式落盘,否则丢弃临时文件 + if (total === 0 || cacheReceived >= total) { + fs.rename(tmpPath, cacheFilePath, (err: any) => { + if (err) fs.unlink(tmpPath, () => { }) + openlist.markCacheDone(server.id, filePath) + }) + } else { + fs.unlink(tmpPath, () => { }) + openlist.clearCacheProgress(server.id, filePath) + } + }) + }) + resp.on('error', () => { cacheWs.destroy() fs.unlink(tmpPath, () => { }) openlist.clearCacheProgress(server.id, filePath) - } - }) - } - resp.pipe(res) - }) + }) + res.on('close', () => { + // 客户端中断:停止缓存写入并清理临时文件(下次播放重新缓存) + if (!resp.complete) { + cacheWs.destroy() + fs.unlink(tmpPath, () => { }) + openlist.clearCacheProgress(server.id, filePath) + } + }) + } + resp.pipe(res) + }) + } + followRedirect(proxyReq) req.on('close', () => { if (!proxyReq.destroyed) proxyReq.destroy() }) @@ -5184,6 +5225,29 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro return } + // [新增] OpenList 本地音乐索引(扫描目录树收集音频文件,供本地音乐整合) + if (pathname === '/api/openlist/local-list' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const refresh = urlObj.searchParams.get('refresh') === '1' || urlObj.searchParams.get('refresh') === 'true' + try { + const files = serverId + ? await openlist.getLocalIndex(serverId, refresh) + : await openlist.getAllLocalIndex(refresh) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, total: files.length, items: files })) + } catch (e: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message })) + } + return + } + // [新增] OpenList 清空本地缓存(管理员) if (pathname === '/api/openlist/cache/clear' && req.method === 'POST') { const username = requirePlayerOrAdmin() diff --git a/src/types/config.d.ts b/src/types/config.d.ts index 9d33e3d8..cd1e38f9 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -99,6 +99,10 @@ declare namespace LX { * 缓存空间限制大小 (MB) */ 'user.cacheSizeLimit'?: number + /** + * 是否将 OpenList 目录整合到本地音乐列表 + */ + 'user.enableOpenListInLocalMusic'?: boolean /** * 公共最大备份快照数 From 0e5a4f0d6fef7d893f377a7b31c76469202905c4 Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Tue, 4 Aug 2026 11:40:43 +0000 Subject: [PATCH 05/39] =?UTF-8?q?docs:=20README=20=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E9=87=8D=E6=8E=92=E4=B8=BA=20NAS=20Docker=20?= =?UTF-8?q?Compose=20=E4=BC=98=E5=85=88=E5=B9=B6=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 部署方式重排: NAS Docker Compose 一键部署置顶, 补充 migrate-to-nas.sh 迁移脚本用法与 ./data 备份说明 - 新增群晖 SPK 套件安装指引与 Subsonic 访问地址 - 链接归属由 XCQ0607/lxserver 全部替换为 boy6656598/lxserver - 删除不存在的 star history 图表与 docs 外链 --- README.md | 151 +++++++++++++++++++++++---------------------------- README_EN.md | 149 ++++++++++++++++++++++++-------------------------- 2 files changed, 139 insertions(+), 161 deletions(-) diff --git a/README.md b/README.md index d272b2fd..b2560020 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,25 @@ # LX Music Sync Server (Enhanced Edition) -![lxserver](https://socialify.git.ci/XCQ0607/lxserver/image?description=1&forks=0&issues=0&logo=https://raw.githubusercontent.com/XCQ0607/lxserver/refs/heads/main/public/icon.svg&owner=1&pulls=0&stargazers=0&theme=Auto) +![lxserver](https://socialify.git.ci/boy6656598/lxserver/image?description=1&forks=0&issues=0&logo=https://raw.githubusercontent.com/boy6656598/lxserver/refs/heads/main/public/icon.svg&owner=1&pulls=0&stargazers=0&theme=Auto)
    - -

    Build Status Version Node Version - License + License

    -
    GitHub stars - GitHub forks - GitHub issues - Last Commit - Commit Activity - Total Downloads + GitHub stars + GitHub forks + GitHub issues + Last Commit + Commit Activity + Total Downloads

    -[帮助文档 Documentation](https://xcq0607.github.io/lxserver/) | [同步服务器 SyncServer](md/lxserver.md) | [更新日志 Changelog](changelog.md) | [English](README_EN.md) +[同步服务器 SyncServer](md/lxserver.md) | [更新日志 Changelog](changelog.md) | [English](README_EN.md) --- @@ -154,71 +151,45 @@ Web 播放器针对移动端进行了深度优化,手机浏览器访问也能 ## 🚀 快速启动 -本项目基于 **Node.js** 开发,支持多种部署方式。 +本项目基于 **Node.js** 开发,支持多种部署方式。推荐在 NAS / VPS 上使用 **Docker Compose** 一键部署。 -### 方式一:桌面客户端 +### 方式一:NAS / Docker Compose 一键部署(推荐) -可以通过桌面端更方便地运行 LX Music Sync Server,支持 Windows、macOS 和 Linux。 +适用于飞牛 NAS、群晖 NAS、及任何支持 Docker 的主机。项目内置了 `Dockerfile` 与 `docker-compose.yml`,可直接构建运行。 -- **📦 最新版本下载**: [GitHub Releases](https://github.com/XCQ0607/lxserver/releases/latest) -- **✨ 桌面端优势**: - - **单窗口管理**: 服务器管理与 Web 播放器合二为一,界面更统一。 - - **托盘常驻**: 窗口关闭后自动缩回托盘,服务在后台始终运行。 - - **全架构支持**: 提供 Windows (x64/x86/ARM64 Setup 及 Portable)、macOS (Intel/Apple Silicon) 及 Linux (amd64/arm64/armv7l) 全家桶。 +**已有数据迁移(可选)**:如果你已有运行中的数据(用户、OpenList、卡密、云盘配置、缓存),先执行迁移脚本生成部署包: -### 方式二:使用 Docker +```bash +# 在项目目录执行,生成 lxserver-nas-deploy.tar.gz(含清洗后的 config.js + 完整 data/) +bash scripts/migrate-to-nas.sh +``` -本项目支持从 Docker Hub 或 GitHub Packages 拉取镜像: +**全新安装 / 迁移安装通用步骤:** -- **Docker Hub**: `xcq0607/lxserver:latest` -- **GitHub Packages**: `ghcr.io/xcq0607/lxserver:latest` +```bash +# 1. 将项目代码放到 NAS 固定目录 +mkdir -p /vol1/docker/lxserver +# (若迁移,把上面的部署包也解压进来) +tar -xzf lxserver-nas-deploy.tar.gz -C /vol1/docker/lxserver -**Docker Run 示例:** +# 2. 构建并启动(首次构建需编译 TS,约 5-15 分钟) +cd /vol1/docker/lxserver && docker compose up -d --build -```bash -docker run -d \ - -p 9527:9527 \ - -v $(pwd)/data:/server/data \ - -v $(pwd)/logs:/server/logs \ - -v $(pwd)/cache:/server/cache \ - -v $(pwd)/music:/server/music \ - --name lx-sync-server \ - --restart unless-stopped \ - xcq0607/lxserver:latest +# 3. 访问 +# 同步管理后台: http://:9527/ +# Web 播放器: http://:9527/music/ ``` -**Docker Compose 示例:** - -新建 `docker-compose.yml` 文件: - -```yaml -version: '3' -services: - lx-sync-server: - image: xcq0607/lxserver:latest - container_name: lx-sync-server - restart: unless-stopped - ports: - - "9527:9527" - volumes: - - ./data:/server/data - - ./logs:/server/logs - - ./cache:/server/cache - - ./music:/server/music - environment: - - NODE_ENV=production - # - FRONTEND_PASSWORD=123456 - # - ENABLE_WEBPLAYER_AUTH=true - # - WEBPLAYER_PASSWORD=yourpassword - # - ADMIN_PATH= - # - PLAYER_PATH=/music -``` +> **提示**: +> - 端口被占用时修改 `docker-compose.yml` 中 `ports` 左侧宿主机端口即可(如 `"9000:9527"`)。 +> - 所有数据(用户、OpenList、云盘配置、缓存)持久化在 `./data` 目录,**备份只需复制该目录**。 +> - 常用环境变量(优先级高于 `config.js`)见 `docker-compose.yml` 内注释:`LX_USER_<用户名>` 追加用户、`FRONTEND_PASSWORD` 后台密码、`ENABLE_WEBPLAYER_AUTH` / `WEBPLAYER_PASSWORD` 播放器密码、`WEBDAV_URL` 等开启 WebDAV 同步。 -### 方式三:直接运行 (Git Clone) +### 方式二:直接运行 (Git Clone) ```bash # 1. 克隆项目 -git clone https://github.com/XCQ0607/lxserver.git && cd lxserver +git clone https://github.com/boy6656598/lxserver.git && cd lxserver # 2. 安装依赖并编译 npm ci && npm run build @@ -227,16 +198,45 @@ npm ci && npm run build npm start ``` -### 方式四:使用 Release 版本 +### 方式三:桌面客户端 + +可以通过桌面端更方便地运行 LX Music Sync Server,支持 Windows、macOS 和 Linux。 + +- **📦 最新版本下载**: [GitHub Releases](https://github.com/boy6656598/lxserver/releases/latest) +- **✨ 桌面端优势**: + - **单窗口管理**: 服务器管理与 Web 播放器合二为一,界面更统一。 + - **托盘常驻**: 窗口关闭后自动缩回托盘,服务在后台始终运行。 + - **全架构支持**: 提供 Windows (x64/x86/ARM64 Setup 及 Portable)、macOS (Intel/Apple Silicon) 及 Linux (amd64/arm64/armv7l) 全家桶。 + +### 方式四:使用 Docker 镜像 + +```bash +docker run -d \ + -p 9527:9527 \ + -v $(pwd)/data:/server/data \ + -v $(pwd)/logs:/server/logs \ + -v $(pwd)/cache:/server/cache \ + -v $(pwd)/music:/server/music \ + --name lx-sync-server \ + --restart unless-stopped \ + boy6656598/lxserver:latest +``` + +### 方式五:使用 Release 版本 1. 在 GitHub Releases 下载压缩包。 2. 解压后运行 `npm install --production`。 3. 执行 `npm start` 启动。 -### 3. 访问说明 +### 群晖套件安装 + +群晖用户也可使用项目内置的 SPK 打包方案(`packaging/synology-spk/`),支持 DSM 7 手动安装套件,详见 [packaging/synology-spk/README.md](packaging/synology-spk/README.md)。 + +### 访问说明 -- **Web 播放器**: `http://your-ip:9527/music` (默认路径,可通过 `PLAYER_PATH` 修改) - **同步管理后台**: `http://your-ip:9527` (默认路径,可通过 `ADMIN_PATH` 修改,默认密码: `123456`) +- **Web 播放器**: `http://your-ip:9527/music` (默认路径,可通过 `PLAYER_PATH` 修改) +- **Subsonic**: `http://your-ip:9527/rest` (可被音流、Feishin 等客户端连接) --- @@ -334,28 +334,15 @@ npm start ### 👥 贡献者 (Contributors) - - + + - -## 📈 Star History - - - - - - Star History Chart - - - - - ## 📄 开源协议 本项目基于 Apache License 2.0 许可证发行,以下协议是对于 Apache License 2.0 的补充,如有冲突,以以下协议为准。 -Apache License 2.0 copyright (c) 2026 [xcq0607](https://github.com/xcq0607) +Apache License 2.0 copyright (c) 2026 [boy6656598](https://github.com/boy6656598) **词语约定**:本协议中的“本项目”指 LX Music Web 播放器;“使用者”指签署本协议的使用者;“官方音乐平台”指对本项目内置的包括酷我、酷狗、咪咕等音乐源的官方平台统称;“版权数据”指包括但不限于图像、音频、名字等在内的他人拥有所属版权的数据。 diff --git a/README_EN.md b/README_EN.md index d8f72550..377ea079 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,25 +1,25 @@ # LX Music Sync Server (Enhanced Edition) -![lxserver](https://socialify.git.ci/XCQ0607/lxserver/image?description=1&forks=0&issues=0&logo=https://raw.githubusercontent.com/XCQ0607/lxserver/refs/heads/main/public/icon.svg&owner=1&pulls=0&stargazers=0&theme=Auto) +![lxserver](https://socialify.git.ci/boy6656598/lxserver/image?description=1&forks=0&issues=0&logo=https://raw.githubusercontent.com/boy6656598/lxserver/refs/heads/main/public/icon.svg&owner=1&pulls=0&stargazers=0&theme=Auto)

    Build Status Version Node Version - License + License

    - GitHub stars - GitHub forks - GitHub issues - Last Commit - Commit Activity - Total Downloads + GitHub stars + GitHub forks + GitHub issues + Last Commit + Commit Activity + Total Downloads

    -[Documentation](https://xcq0607.github.io/lxserver/) | [SyncServer](md/lxserver_EN.md) | [Changelog](changelog.md) | [中文版](README.md) +[SyncServer](md/lxserver_EN.md) | [Changelog](changelog.md) | [中文版](README.md) --- This project features a powerful built-in **Web Player**, allowing you to enjoy music anywhere in your browser. It also serves as an enhanced [LX Music Data Sync Server](md/lxserver_EN.md). @@ -142,14 +142,58 @@ The Web Player is deeply optimized for mobile devices, providing a native App-li ## 🚀 Quick Start -Built with **Node.js**, supporting multiple deployment methods. +Built with **Node.js**, supporting multiple deployment methods. For NAS / VPS, **Docker Compose** is the recommended one-click deployment. +### Option 1: NAS / Docker Compose One-Click Deployment (Recommended) -### Option 1: Desktop Client +Works on FeiNiu NAS, Synology NAS, and any Docker-capable host. This repo ships with `Dockerfile` and `docker-compose.yml`, so you can build and run directly. + +**Migrate existing data (optional)**: If you already have runtime data (users, OpenList, card keys, cloud drive config, cache), run the migration script first: + +```bash +# Generates lxserver-nas-deploy.tar.gz (cleaned config.js + full data/) +bash scripts/migrate-to-nas.sh +``` + +**Fresh install / migration, common steps:** + +```bash +# 1. Put the project code in a fixed NAS directory +mkdir -p /vol1/docker/lxserver +# (for migration, extract the deploy package here as well) +tar -xzf lxserver-nas-deploy.tar.gz -C /vol1/docker/lxserver + +# 2. Build and start (first build compiles TS, ~5-15 min) +cd /vol1/docker/lxserver && docker compose up -d --build + +# 3. Access +# Sync Dashboard: http://:9527/ +# Web Player: http://:9527/music/ +``` + +> **Notes**: +> - If the port is taken, change the left-hand host port under `ports` in `docker-compose.yml` (e.g. `"9000:9527"`). +> - All data (users, OpenList, cloud drive config, cache) persists in `./data` — **backing up is just copying that directory**. +> - Common env vars (higher priority than `config.js`) are documented as comments in `docker-compose.yml`: `LX_USER_` to add users, `FRONTEND_PASSWORD` for dashboard password, `ENABLE_WEBPLAYER_AUTH` / `WEBPLAYER_PASSWORD` for player password, `WEBDAV_URL` etc. to enable WebDAV sync. + +### Option 2: Manual Run (Git Clone) + +```bash +# 1. Clone project +git clone https://github.com/boy6656598/lxserver.git && cd lxserver + +# 2. Install dependencies and build +npm ci && npm run build + +# 3. Start service +npm start +``` + +### Option 3: Desktop Client You can now run LX Music Sync Server more conveniently via our Desktop Client, available for Windows, macOS, and Linux. -- **📦 Download Latest**: [GitHub Releases](https://github.com/XCQ0607/lxserver/releases/latest) +- **📦 Download Latest**: [GitHub Releases](https://github.com/boy6656598/lxserver/releases/latest) - **✨ Key Advantages**: - **Single Window**: Integrated management dashboard and Web player for a unified experience. - **System Tray**: Minimizes to tray on close, ensuring the sync service stays active in the background. @@ -157,13 +201,7 @@ You can now run LX Music Sync Server more conveniently via our Desktop Client, a - **Setup Wizard**: Guided data path selection on first launch, supports **Portable Mode**. - **Multi-Arch Support**: Builds for Windows (x64/x86/ARM64 Setup & Portable), macOS (Intel x64 & Apple Silicon arm64), and Linux (amd64/arm64/armv7l deb/AppImage). -### Option 2: Containerized Deployment via Docker - -This project supports pulling images from Docker Hub or GitHub Packages: -- **Docker Hub**: `xcq0607/lxserver:latest` -- **GitHub Packages**: `ghcr.io/xcq0607/lxserver:latest` - -**Docker Run Example:** +### Option 4: Containerized Deployment via Docker ```bash docker run -d \ @@ -174,59 +212,24 @@ docker run -d \ -v $(pwd)/music:/server/music \ --name lx-sync-server \ --restart unless-stopped \ - xcq0607/lxserver:latest -``` - -**Docker Compose Example:** - -Create a `docker-compose.yml` file: - -```yaml -version: '3' -services: - lx-sync-server: - image: xcq0607/lxserver:latest - container_name: lx-sync-server - restart: unless-stopped - ports: - - "9527:9527" - volumes: - - ./data:/server/data - - ./logs:/server/logs - - ./cache:/server/cache - - ./music:/server/music - environment: - - NODE_ENV=production - # - FRONTEND_PASSWORD=123456 - # - ENABLE_WEBPLAYER_AUTH=true - # - WEBPLAYER_PASSWORD=yourpassword - # - ADMIN_PATH= - # - PLAYER_PATH=/music -``` - -### Option 3: Manual Run (Git Clone) - -```bash -# 1. Clone project -git clone https://github.com/XCQ0607/lxserver.git && cd lxserver - -# 2. Install dependencies and build -npm ci && npm run build - -# 3. Start service -npm start + boy6656598/lxserver:latest ``` -### Option 4: Using Release Build +### Option 5: Using Release Build 1. Download the archive from GitHub Releases. 2. Extract and run `npm install --production`. 3. Execute `npm start`. -### 3. Access Info +### Synology Package (SPK) + +Synology users can also use the bundled SPK packaging (`packaging/synology-spk/`) for DSM 7 manual install, see [packaging/synology-spk/README.md](packaging/synology-spk/README.md). + +### Access Info -- **Web Player**: `http://your-ip:9527/music` (Default path, configurable via `PLAYER_PATH`) - **Sync Dashboard**: `http://your-ip:9527` (Default path, configurable via `ADMIN_PATH`, default password: `123456`) +- **Web Player**: `http://your-ip:9527/music` (Default path, configurable via `PLAYER_PATH`) +- **Subsonic**: `http://your-ip:9527/rest` (connectable by YinLiu, Feishin and other clients) --- @@ -324,29 +327,17 @@ Anonymous telemetry via PostHog is used for: ### 👥 Contributors - - + + -## 📈 Star History - - - - - - - Star History Chart - - - - ---- +## 📄 Open Source License--- ## 📄 License This project is released under the Apache License 2.0. The following agreement is a supplement to the Apache License 2.0. In case of conflict, this agreement shall prevail. -Apache License 2.0 copyright (c) 2026 [xcq0607](https://github.com/xcq0607) +Apache License 2.0 copyright (c) 2026 [boy6656598](https://github.com/boy6656598) **Terminology**: "This Project" refers to LX Music Web Player; "User" refers to the user who agrees to this agreement; "Official Music Platforms" refers to the collective official platforms of the music sources built into this project, including Kuwo, Kugou, Migu, etc.; "Copyrighted Data" refers to data owned by others, including but not limited to images, audio, names, etc. From d8a19aaa2de2750e0a2f3213a40741bc6588e5a5 Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Tue, 4 Aug 2026 11:45:12 +0000 Subject: [PATCH 06/39] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=20Docker=20?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E6=96=B9=E5=BC=8F=E4=B8=BA=E8=87=AA=E8=A1=8C?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E9=95=9C=E5=83=8F(=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E6=9C=AA=E5=8F=91=E5=B8=83=E5=88=B0=20Docker=20Hub)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 ++++++++-- README_EN.md | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b2560020..559b30ea 100644 --- a/README.md +++ b/README.md @@ -208,9 +208,15 @@ npm start - **托盘常驻**: 窗口关闭后自动缩回托盘,服务在后台始终运行。 - **全架构支持**: 提供 Windows (x64/x86/ARM64 Setup 及 Portable)、macOS (Intel/Apple Silicon) 及 Linux (amd64/arm64/armv7l) 全家桶。 -### 方式四:使用 Docker 镜像 +### 方式四:自行构建 Docker 镜像 + +> 本项目镜像暂未发布到 Docker Hub,需先构建后再运行(等同于方式一的 Compose 构建): ```bash +# 1. 构建镜像 +docker build -t lxserver . + +# 2. 运行 docker run -d \ -p 9527:9527 \ -v $(pwd)/data:/server/data \ @@ -219,7 +225,7 @@ docker run -d \ -v $(pwd)/music:/server/music \ --name lx-sync-server \ --restart unless-stopped \ - boy6656598/lxserver:latest + lxserver ``` ### 方式五:使用 Release 版本 diff --git a/README_EN.md b/README_EN.md index 377ea079..7a407b9a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -201,9 +201,15 @@ You can now run LX Music Sync Server more conveniently via our Desktop Client, a - **Setup Wizard**: Guided data path selection on first launch, supports **Portable Mode**. - **Multi-Arch Support**: Builds for Windows (x64/x86/ARM64 Setup & Portable), macOS (Intel x64 & Apple Silicon arm64), and Linux (amd64/arm64/armv7l deb/AppImage). -### Option 4: Containerized Deployment via Docker +### Option 4: Build Your Own Docker Image + +> The image is not published to Docker Hub yet — build it first, then run (same as the Compose build in Option 1): ```bash +# 1. Build the image +docker build -t lxserver . + +# 2. Run docker run -d \ -p 9527:9527 \ -v $(pwd)/data:/server/data \ @@ -212,7 +218,7 @@ docker run -d \ -v $(pwd)/music:/server/music \ --name lx-sync-server \ --restart unless-stopped \ - boy6656598/lxserver:latest + lxserver ``` ### Option 5: Using Release Build From 6c143b24aecf7e3c4c31ee87402a0bfb317a02a4 Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Tue, 4 Aug 2026 12:47:30 +0000 Subject: [PATCH 07/39] =?UTF-8?q?docs:=20Docker=20=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=BD=BF=E7=94=A8=20ghcr.io=20=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E5=B9=B6=E4=BF=9D=E7=95=99=E6=BA=90=E7=A0=81=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 ++++++++++++++++----- README_EN.md | 21 ++++++++++++++++----- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 559b30ea..50355bad 100644 --- a/README.md +++ b/README.md @@ -208,15 +208,26 @@ npm start - **托盘常驻**: 窗口关闭后自动缩回托盘,服务在后台始终运行。 - **全架构支持**: 提供 Windows (x64/x86/ARM64 Setup 及 Portable)、macOS (Intel/Apple Silicon) 及 Linux (amd64/arm64/armv7l) 全家桶。 -### 方式四:自行构建 Docker 镜像 +### 方式四:使用 Docker 镜像 -> 本项目镜像暂未发布到 Docker Hub,需先构建后再运行(等同于方式一的 Compose 构建): +> 镜像发布在 **GitHub Container Registry**(Docker Hub 发布见下方说明): ```bash -# 1. 构建镜像 -docker build -t lxserver . +docker run -d \ + -p 9527:9527 \ + -v $(pwd)/data:/server/data \ + -v $(pwd)/logs:/server/logs \ + -v $(pwd)/cache:/server/cache \ + -v $(pwd)/music:/server/music \ + --name lx-sync-server \ + --restart unless-stopped \ + ghcr.io/boy6656598/lxserver:latest +``` -# 2. 运行 +**从源码构建镜像(如需自定义或自行发布 Docker Hub):** + +```bash +docker build -t lxserver . docker run -d \ -p 9527:9527 \ -v $(pwd)/data:/server/data \ diff --git a/README_EN.md b/README_EN.md index 7a407b9a..ce340c24 100644 --- a/README_EN.md +++ b/README_EN.md @@ -201,15 +201,26 @@ You can now run LX Music Sync Server more conveniently via our Desktop Client, a - **Setup Wizard**: Guided data path selection on first launch, supports **Portable Mode**. - **Multi-Arch Support**: Builds for Windows (x64/x86/ARM64 Setup & Portable), macOS (Intel x64 & Apple Silicon arm64), and Linux (amd64/arm64/armv7l deb/AppImage). -### Option 4: Build Your Own Docker Image +### Option 4: Containerized Deployment via Docker -> The image is not published to Docker Hub yet — build it first, then run (same as the Compose build in Option 1): +> Image published on **GitHub Container Registry** (see below for building your own / publishing to Docker Hub): ```bash -# 1. Build the image -docker build -t lxserver . +docker run -d \ + -p 9527:9527 \ + -v $(pwd)/data:/server/data \ + -v $(pwd)/logs:/server/logs \ + -v $(pwd)/cache:/server/cache \ + -v $(pwd)/music:/server/music \ + --name lx-sync-server \ + --restart unless-stopped \ + ghcr.io/boy6656598/lxserver:latest +``` -# 2. Run +**Build the image from source (for customization or publishing to Docker Hub):** + +```bash +docker build -t lxserver . docker run -d \ -p 9527:9527 \ -v $(pwd)/data:/server/data \ From 0b17e1542529bfe4f0f180774e7e1ea4632f99d5 Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Tue, 4 Aug 2026 15:08:02 +0000 Subject: [PATCH 08/39] =?UTF-8?q?fix:=20=E5=BC=BA=E5=88=B6=20DNS=20?= =?UTF-8?q?=E4=BC=98=E5=85=88=20IPv4,=20=E4=BF=AE=E5=A4=8D=20OpenList/WebD?= =?UTF-8?q?AV=20=E8=BF=9E=E6=8E=A5=20ENETUNREACH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alist.embyfd.cc.cd 等域名同时解析出 IPv6(AAAA), Node 默认优先 IPv6, 而部分部署环境 IPv6 路由不可达导致 connect ENETUNREACH。在入口设置 dns.setDefaultResultOrder('ipv4first') 覆盖 needle/webdav/原生 http 全部出站请求 --- src/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/index.ts b/src/index.ts index 0f7a6123..af82f771 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import fs from 'fs' import path from 'path' import crypto from 'crypto' +import dns from 'dns' import moduleAlias from 'module-alias' // @ts-ignore moduleAlias.addAliases({ @@ -11,6 +12,14 @@ moduleAlias.addAliases({ '@': __dirname }) +// 强制 DNS 优先 IPv4:部分环境 IPv6 路由不可达(如 alist 域名解析出 AAAA 后 ENETUNREACH) +// 需在 http/https 请求前设置,覆盖 needle / webdav / 原生 http 等所有出站请求 +try { + dns.setDefaultResultOrder('ipv4first') +} catch (e) { + // Node <17 不支持该 API,忽略 +} + if (typeof (global as any).navigator === 'undefined') { (global as any).navigator = { userAgent: 'node.js' } } From d7b55d894d71c546f73a708fc00e69d941c71491 Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Tue, 4 Aug 2026 15:09:43 +0000 Subject: [PATCH 09/39] chore: update build hash --- public/js/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/config.js b/public/js/config.js index 590bf828..f227e4bb 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -2,6 +2,6 @@ // 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts) // 服务端拦截 /js/config.js 请求, 读取此处版本号并合并服务端配置后返回 window.CONFIG = { - buildHash: '36d6ae5', + buildHash: '37868c6', version: 'v2.0.0', }; From b5cc135ef535ff5e7acca1419c36df2117acae7b Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Wed, 5 Aug 2026 00:51:18 +0000 Subject: [PATCH 10/39] feat: remove alidrive & fix local playback & intranet mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除阿里云盘功能:删除 alidrive.ts/aliyun_manager.js、全部 /api/alidrive/* 路由及前后端 UI 引用 - 修复本地音乐播放:serveCacheFile 增加 Range 416 校验与 suffix range 支持,避免 seek 时崩溃 - 修复 OpenList/WebDAV 内网地址挂载:无协议地址默认补 http:// 而非强制 https --- public/app.js | 119 -------- public/index.html | 71 ----- public/music/app.js | 35 +-- public/music/index.html | 52 ---- public/music/js/aliyun_manager.js | 391 -------------------------- src/defaultConfig.ts | 1 - src/server/alidrive.ts | 452 ------------------------------ src/server/fileCache.ts | 28 +- src/server/openlist.ts | 3 +- src/server/server.ts | 357 +---------------------- src/types/config.d.ts | 5 - src/utils/webdavSync.ts | 11 +- 12 files changed, 41 insertions(+), 1484 deletions(-) delete mode 100644 public/music/js/aliyun_manager.js delete mode 100644 src/server/alidrive.ts diff --git a/public/app.js b/public/app.js index f4aec9ff..582fd6b8 100644 --- a/public/app.js +++ b/public/app.js @@ -277,7 +277,6 @@ class App { files: '文件管理', snapshots: '快照管理', cards: '卡密管理', - alidrive: '阿里云盘', about: '关于' }; document.getElementById('page-title').textContent = titles[viewName] || viewName; @@ -316,9 +315,6 @@ class App { case 'cards': this.loadCards(); break; - case 'alidrive': - this.loadAlidrive(); - break; case 'openlist': this.loadOpenList(); break; @@ -2070,121 +2066,6 @@ class App { }); } - // ========== 阿里云盘 ========== - async loadAlidrive() { - try { - const res = await this.request('/api/alidrive/config'); - document.getElementById('alidrive-client-id').value = res.clientId || ''; - document.getElementById('alidrive-client-secret').value = res.clientSecret || ''; - this.updateAlidriveStatus(res); - } catch (err) { - showError('加载阿里云盘配置失败: ' + err.message); - } - } - - updateAlidriveStatus(res) { - const statusEl = document.getElementById('alidrive-status'); - if (!statusEl) return; - const linked = res && res.linked; - const userName = (res && res.userName) || ''; - if (linked) { - statusEl.innerHTML = ` - 已绑定 - 账号:${this.escapeHtml(userName || '未知')}`; - document.getElementById('alidrive-unlink-btn').style.display = 'inline-block'; - } else { - const hasClient = !!(document.getElementById('alidrive-client-id').value && document.getElementById('alidrive-client-secret').value); - statusEl.innerHTML = hasClient - ? '尚未绑定,请点击"获取二维码"扫码登录' - : '尚未配置应用凭据,请先填写 ClientID 与 ClientSecret 并保存'; - document.getElementById('alidrive-unlink-btn').style.display = 'none'; - } - } - - async saveAlidriveClient() { - const clientId = document.getElementById('alidrive-client-id').value.trim(); - const clientSecret = document.getElementById('alidrive-client-secret').value.trim(); - if (!clientId || !clientSecret) { - showError('请填写 ClientID 与 ClientSecret'); - return; - } - try { - await this.request('/api/alidrive/config', { - method: 'POST', - body: JSON.stringify({ clientId, clientSecret }) - }); - showSuccess('凭据已保存'); - this.updateAlidriveStatus({ linked: false }); - } catch (err) { - showError('保存失败: ' + err.message); - } - } - - async startAlidriveQrLogin() { - const qrImg = document.getElementById('alidrive-qrcode-img'); - const placeholder = document.getElementById('alidrive-qrcode-placeholder'); - placeholder.style.display = 'flex'; - placeholder.textContent = '正在获取二维码...'; - qrImg.style.display = 'none'; - qrImg.innerHTML = ''; - - let sid = ''; - try { - const res = await this.request('/api/alidrive/qrcode', { method: 'POST' }); - sid = res.sid; - if (!res.qr_content) throw new Error('未获取到二维码内容'); - - placeholder.style.display = 'none'; - qrImg.style.display = 'block'; - qrImg.innerHTML = `扫码登录二维码`; - this.pollAlidriveQr(sid); - } catch (err) { - placeholder.style.display = 'flex'; - placeholder.textContent = '获取二维码失败: ' + err.message; - } - } - - async pollAlidriveQr(sid) { - let qrImg = document.getElementById('alidrive-qrcode-img'); - let placeholder = document.getElementById('alidrive-qrcode-placeholder'); - for (let i = 0; i < 60; i++) { - await new Promise(r => setTimeout(r, 2000)); - try { - const res = await this.request(`/api/alidrive/qrcode/status?sid=${encodeURIComponent(sid)}`); - if (res.status === 'LoginSuccess') { - placeholder.style.display = 'flex'; - placeholder.textContent = '扫码成功,绑定完成!'; - qrImg.style.display = 'none'; - showSuccess('阿里云盘绑定成功'); - this.loadAlidrive(); - return; - } - if (res.status === 'Expired' || res.status === 'Cancel') { - placeholder.style.display = 'flex'; - placeholder.textContent = '二维码已失效,请重新获取'; - qrImg.style.display = 'none'; - return; - } - } catch (err) { - // 继续轮询 - } - } - placeholder.style.display = 'flex'; - placeholder.textContent = '等待扫码超时,请重新获取二维码'; - qrImg.style.display = 'none'; - } - - async unlinkAlidrive() { - if (!confirm('确定解除阿里云盘绑定吗?')) return; - try { - await this.request('/api/alidrive/unlink', { method: 'POST' }); - showSuccess('已解除绑定'); - this.loadAlidrive(); - } catch (err) { - showError('解除绑定失败: ' + err.message); - } - } - // ========== OpenList ========== async loadOpenList() { try { diff --git a/public/index.html b/public/index.html index 7081330c..4063243c 100644 --- a/public/index.html +++ b/public/index.html @@ -147,16 +147,6 @@

    LX Sync

    卡密管理 - - - - - - - - - 阿里云盘 - @@ -1315,67 +1305,6 @@

    卡密管理

    - -
    -
    -
    -

    阿里云盘

    -

    配置阿里云盘开放平台凭据并扫码登录,支持云端音乐播放、自动下载与歌词识别。

    -
    -
    - - -
    -

    1. 应用凭据配置

    -

    阿里云盘开放平台为申请制,请先填写 对接申请表 - 并通过审核(审核结果将通知到你的阿里云盘客户端)。审核通过后,在阿里云盘客户端中进入"开放平台"即可创建应用,获取 ClientID 与 ClientSecret,授权范围需包含 file:all:read 与 file:all:write。

    -
    -
    - - -
    -
    - - -
    -
    -
    - -
    -
    - - -
    -

    2. 扫码登录绑定

    -
    未绑定
    -
    -
    -
    - 点击"获取二维码"开始绑定 -
    - -
    - - -
    -
    -
    -
    - - -
    -

    使用说明

    -
      -
    • 绑定成功后,播放器侧边栏将出现"阿里云盘"入口,可浏览并播放云端音频。
    • -
    • 在播放器下载歌曲时,可选择"下载到阿里云盘"将音乐上传至云端目录(默认 /music/lxserver)。
    • -
    • 播放云端音频时,若同目录存在同名 .lrc 文件,将自动识别并加载歌词。
    • -
    -
    -
    -
    diff --git a/public/music/app.js b/public/music/app.js index a51dcd08..8f66fb71 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -1098,11 +1098,6 @@ function switchTab(tabId) { document.getElementById('page-title').innerText = "本地音乐"; } - if (tabId === 'alidrive') { - document.getElementById('page-title').innerText = "阿里云盘"; - if (window.AliyunManager) window.AliyunManager.refresh(); - } - if (tabId === 'openlist') { document.getElementById('page-title').innerText = "OpenList"; if (window.OpenListManager) window.OpenListManager.init(); @@ -3649,7 +3644,7 @@ async function fetchSongUrl(song, quality, isRetry = false, isSilent = false) { const cacheKey = `lx_url_${cleanedSong.id}_${quality}`; // 0. 本地文件/带有本地播放 URL 的歌曲:直接播放本地文件,无需走在线 API 解析 - if ((song.isLocal || song.url?.startsWith('/api/music/cache/file/') || song.url?.startsWith('/api/alidrive/stream') || song.url?.startsWith('/api/openlist/stream')) && song.url && !isRetry) { + if ((song.isLocal || song.url?.startsWith('/api/music/cache/file/') || song.url?.startsWith('/api/openlist/stream')) && song.url && !isRetry) { console.log(`[Cache] Direct Local File Hit: ${song.name}`); let localUrl = await applyAutoProxy(song.url, song); return { url: localUrl, sourceType: 'server_cache', quality: song.quality || quality }; @@ -7085,34 +7080,6 @@ async function fetchLyric(song, quality = null) { } } - // ===== 2.5 阿里云盘歌曲:从云盘同目录读取 .lrc 歌词 ===== - if (source === 'alipan' && song.fileId) { - try { - const lyricRes = await fetch(`/api/alidrive/lyric?fileId=${encodeURIComponent(song.fileId)}`, { headers }); - if (lyricRes.ok) { - const lyricData = await lyricRes.json(); - const lrcText = (lyricData && lyricData.lyric) || ''; - if (lrcText) { - currentRawLrc = lrcText; - currentRawTlrc = ''; - currentRawRlrc = ''; - currentRawKlrc = ''; - if (settings.enableLyricCache !== false) { - try { - localStorage.setItem(cacheKey, JSON.stringify({ lrc: lrcText, tlyric: '', rlyric: '', klyric: '' })); - } catch (e) { } - } - initLyricPlayer(); - applyLyricUpdate(); - return; - } - } - } catch (e) { - console.warn('[Lyric] 阿里云盘歌词获取失败:', e); - } - renderLyric([], '暂无歌词'); - return; - } // ===== 2.6 OpenList 歌曲:从同目录读取 .lrc 歌词 ===== if (source === 'openlist' && song.path) { diff --git a/public/music/index.html b/public/music/index.html index 814f9df9..781560ae 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -120,13 +120,6 @@

    LX MUSIC

    本地音乐 -
  • - - - 阿里云盘 - -
  • @@ -1414,50 +1407,6 @@

    本地

  • - - - + +
    +
    +
    +

    WebDAV 挂载

    +

    挂载 WebDAV 服务器作为音乐存储源,支持浏览、播放、边播边缓存到本地与收藏为歌单。

    +
    +
    + +
    +
    + +
    +
    正在加载挂载源列表...
    +
    + + + +
    +
    diff --git a/public/js/config.js b/public/js/config.js index f227e4bb..cb51ad5e 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -2,6 +2,6 @@ // 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts) // 服务端拦截 /js/config.js 请求, 读取此处版本号并合并服务端配置后返回 window.CONFIG = { - buildHash: '37868c6', + buildHash: 'fff0ad8', version: 'v2.0.0', }; diff --git a/public/music/index.html b/public/music/index.html index 781560ae..ebc6349c 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -1199,6 +1199,7 @@

    本地 +

    @@ -1366,6 +1367,40 @@

    本地

    + +
    + + +
    +
    diff --git a/public/music/js/local_music.js b/public/music/js/local_music.js index ca845318..1f7a3de9 100644 --- a/public/music/js/local_music.js +++ b/public/music/js/local_music.js @@ -788,11 +788,11 @@ window.LocalMusicManager = { this.applyFilters(); }, - // 控制"目录加歌单"按钮显示:仅 OpenList / 下载 目录下可用 + // 控制"目录加歌单"按钮显示:仅 OpenList / WebDAV / 下载 目录下可用 syncDirPlaylistBtn() { const btn = document.getElementById('lm-add-dir-playlist-btn'); if (!btn) return; - const show = this.filterFolder === 'openlist' || this.filterFolder === 'music'; + const show = this.filterFolder === 'openlist' || this.filterFolder === 'webdav' || this.filterFolder === 'music'; btn.classList.toggle('hidden', !show); }, @@ -1083,6 +1083,301 @@ window.LocalMusicManager = { window.openPlaylistAddModal(audios.map(file => this.olBuildSong(file)).filter(Boolean)); }, + // ===== 内嵌 WebDAV 挂载目录树浏览面板 ===== + toggleWebdavPanel() { + const body = document.getElementById('lm-wm-panel-body'); + const arrow = document.getElementById('lm-wm-panel-arrow'); + if (!body) return; + const open = body.classList.contains('hidden'); + body.classList.toggle('hidden', !open); + if (arrow) arrow.className = open ? 'fas fa-chevron-up text-[10px] t-text-muted' : 'fas fa-chevron-down text-[10px] t-text-muted'; + if (open && !this.wmPanelInitialized) { + this.wmPanelInitialized = true; + this.loadWmServers(); + } + }, + + async loadWmServers() { + const headers = {}; + if (window.getUserAuthHeaders) Object.assign(headers, window.getUserAuthHeaders()); + try { + const res = await fetch('/api/webdav-mounts', { headers }); + if (!res.ok) throw new Error('加载失败'); + const data = await res.json(); + this.wmServers = (data.mounts || []).filter(m => m.enabled && m.baseUrl); + const select = document.getElementById('lm-wm-server-select'); + if (!select) return; + const saved = localStorage.getItem('lx_webdav_mount'); + let options = ''; + this.wmServers.forEach(s => { + options += ``; + }); + select.innerHTML = options; + if (saved && this.wmServers.some(s => s.id === saved)) { + select.value = saved; + this.selectWmServer(saved); + } + } catch (err) { + const statusEl = document.getElementById('lm-wm-status'); + if (statusEl) statusEl.textContent = '加载挂载源失败: ' + err.message; + } + }, + + async selectWmServer(serverId) { + this.wmCurrentServerId = serverId; + const server = this.wmServers.find(s => s.id === serverId) || null; + if (serverId) localStorage.setItem('lx_webdav_mount', serverId); + if (!server) { + const statusEl = document.getElementById('lm-wm-status'); + if (statusEl) statusEl.textContent = '请先选择 WebDAV 挂载源'; + const listEl = document.getElementById('lm-wm-file-list'); + if (listEl) listEl.innerHTML = ''; + const crumbEl = document.getElementById('lm-wm-breadcrumb'); + if (crumbEl) crumbEl.innerHTML = ''; + return; + } + await this.refreshWebdav(); + }, + + async refreshWebdav() { + const server = this.wmServers.find(s => s.id === this.wmCurrentServerId) || null; + this.wmCurrentPath = server ? (server.rootPath || '/') : '/'; + this.wmBreadcrumb = [{ path: this.wmCurrentPath, name: '根目录' }]; + this.renderWmBreadcrumb(); + await this.loadWmList(true); + }, + + async loadWmList(reset = true) { + if (!this.wmCurrentServerId) return; + const statusEl = document.getElementById('lm-wm-status'); + const listEl = document.getElementById('lm-wm-file-list'); + if (!statusEl || !listEl) return; + if (reset) listEl.innerHTML = '
    正在加载...
    '; + + const headers = {}; + if (window.getUserAuthHeaders) Object.assign(headers, window.getUserAuthHeaders()); + + let url = `/api/webdav-mounts/browse?server=${encodeURIComponent(this.wmCurrentServerId)}&path=${encodeURIComponent(this.wmCurrentPath)}`; + + try { + const res = await fetch(url, { headers }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || '加载失败'); + } + const data = await res.json(); + if (!data.success) throw new Error(data.message || '加载失败'); + this.wmItems = data.items || []; + this.renderWmList(reset); + } catch (err) { + statusEl.textContent = '加载失败: ' + err.message; + if (reset) listEl.innerHTML = ''; + } + }, + + renderWmBreadcrumb() { + const crumbEl = document.getElementById('lm-wm-breadcrumb'); + if (!crumbEl) return; + let html = ''; + this.wmBreadcrumb.forEach((item, i) => { + if (i === this.wmBreadcrumb.length - 1) { + html += `${this.escapeHtml(item.name)}`; + } else { + html += ` + + + `; + } + }); + crumbEl.innerHTML = html; + }, + + wmGoTo(index) { + this.wmBreadcrumb = this.wmBreadcrumb.slice(0, index + 1); + const target = this.wmBreadcrumb[this.wmBreadcrumb.length - 1]; + this.wmCurrentPath = target.path; + this.renderWmBreadcrumb(); + this.loadWmList(true); + }, + + wmNavigateTo(dirPath, name) { + this.wmCurrentPath = dirPath; + this.wmBreadcrumb.push({ path: dirPath, name }); + this.renderWmBreadcrumb(); + this.loadWmList(true); + }, + + wmGoBack() { + if (this.wmBreadcrumb.length > 1) { + this.wmBreadcrumb.pop(); + const prev = this.wmBreadcrumb[this.wmBreadcrumb.length - 1]; + this.wmCurrentPath = prev.path; + this.renderWmBreadcrumb(); + this.loadWmList(true); + } + }, + + renderWmList(reset) { + const statusEl = document.getElementById('lm-wm-status'); + const listEl = document.getElementById('lm-wm-file-list'); + if (!statusEl || !listEl) return; + + const folders = this.wmItems.filter(it => it.isDir); + const audios = this.wmItems.filter(it => !it.isDir && /\.(mp3|flac|wav|ogg|aac|m4a|ape|wma|opus|alac)$/i.test(it.name)); + const lyricFiles = this.wmItems.filter(it => !it.isDir && /\.(lrc|lrcx)$/i.test(it.name)); + const otherCount = this.wmItems.length - folders.length - audios.length - lyricFiles.length; + + if (!this.wmItems.length) { + statusEl.textContent = '此目录为空'; + if (reset) listEl.innerHTML = '
    此目录为空
    '; + return; + } + statusEl.textContent = `共 ${this.wmItems.length} 项(音频 ${audios.length})`; + + let html = ''; + if (reset && this.wmBreadcrumb.length > 1) { + html += ` +
    + + 返回上级 +
    `; + } + + folders.forEach(it => { + const childPath = (this.wmCurrentPath === '/' ? '' : this.wmCurrentPath) + '/' + it.name; + html += ` +
    + + ${this.escapeHtml(it.name)} +
    `; + }); + + audios.forEach((it, i) => { + const fullPath = (this.wmCurrentPath === '/' ? '' : this.wmCurrentPath) + '/' + it.name; + html += ` +
    + + ${this.escapeHtml(it.name)} + + ${this.escapeHtml(this.formatOlSize(it.size))} + +
    `; + }); + + if (lyricFiles.length) { + html += `
    歌词文件(随歌曲自动识别)
    `; + } + if (otherCount > 0) { + html += `
    其他文件 ${otherCount} 个(已隐藏)
    `; + } + + if (reset) { + listEl.innerHTML = html; + } else { + listEl.insertAdjacentHTML('beforeend', html); + } + this.refreshWmCacheBadges(audios); + }, + + // 异步查询当前 WebDAV 目录音频的缓存状态,更新"已缓存/缓存中"徽标 + async refreshWmCacheBadges(audios) { + if (!this.wmCurrentServerId || !audios.length) return; + const headers = {}; + if (window.getUserAuthHeaders) Object.assign(headers, window.getUserAuthHeaders()); + const changed = {}; + for (const it of audios) { + const fullPath = (this.wmCurrentPath === '/' ? '' : this.wmCurrentPath) + '/' + it.name; + try { + const res = await fetch(`/api/webdav-mounts/cache/check?server=${encodeURIComponent(this.wmCurrentServerId)}&path=${encodeURIComponent(fullPath)}`, { headers }); + if (!res.ok) continue; + const data = await res.json(); + if (!data.success) continue; + let badge = ''; + if (data.cached) { + badge = `已缓存`; + } else if (data.progress && data.progress.done === false && data.progress.total > 0) { + const pct = Math.round((data.progress.received / data.progress.total) * 100); + badge = `缓存中 ${pct}%`; + } + changed[encodeURIComponent(fullPath)] = badge; + } catch (e) { /* 网络错误忽略 */ } + } + for (const key of Object.keys(changed)) { + const el = document.getElementById(`wm-cache-${key}`); + if (el) el.innerHTML = changed[key]; + } + }, + + wmBuildSong(file) { + const name = file.name.replace(/\.[^.]+$/, ''); + const fullPath = (this.wmCurrentPath === '/' ? '' : this.wmCurrentPath) + '/' + file.name; + return { + id: `webdav_${encodeURIComponent(fullPath)}`, + songmid: `webdav_${encodeURIComponent(fullPath)}`, + songId: `webdav_${encodeURIComponent(fullPath)}`, + source: 'webdav', + name, + singer: '', + path: fullPath, + serverId: this.wmCurrentServerId, + url: `/api/webdav-mounts/stream?server=${encodeURIComponent(this.wmCurrentServerId)}&path=${encodeURIComponent(fullPath)}`, + isLocal: true, + webdav: true, + folder: 'webdav', + quality: 'flac', + type: 'flac', + interval: 0 + }; + }, + + wmPlayAudio(fileName, audioIndex = 0) { + const audios = this.wmItems.filter(it => !it.isDir && /\.(mp3|flac|wav|ogg|aac|m4a|ape|wma|opus|alac)$/i.test(it.name)); + if (!audios.length) return; + const playlist = audios.map(f => this.wmBuildSong(f)); + const idx = Math.max(audios.findIndex(a => a.name === fileName), 0); + if (typeof window.updatePlaylist === 'function') { + window.updatePlaylist(playlist, idx, 'webdav'); + } else if (typeof window.playSong === 'function') { + window.playSong(playlist[idx], idx); + } + }, + + wmAddSongToPlaylist(fileName) { + const audios = this.wmItems.filter(it => !it.isDir && /\.(mp3|flac|wav|ogg|aac|m4a|ape|wma|opus|alac)$/i.test(it.name)); + const target = audios.find(a => a.name === fileName); + if (!target) return; + const song = this.wmBuildSong(target); + if (typeof window.openPlaylistAddModal !== 'function') { + if (typeof showError === 'function') showError('歌单组件尚未加载完成'); + return; + } + window.openPlaylistAddModal([song]); + }, + + // 将内嵌面板当前浏览目录下的所有音频保存为歌单 + async wmAddCurrentDirToPlaylist() { + const audios = this.wmItems.filter(it => !it.isDir && /\.(mp3|flac|wav|ogg|aac|m4a|ape|wma|opus|alac)$/i.test(it.name)); + if (!audios.length) { + if (typeof showError === 'function') showError('当前目录没有音频文件'); + return; + } + if (typeof window.openPlaylistAddModal !== 'function') { + if (typeof showError === 'function') showError('歌单组件尚未加载完成'); + return; + } + const dirLabel = this.wmCurrentPath || '/'; + if (typeof showInfo === 'function') showInfo(`正在将目录「${dirLabel}」下的 ${audios.length} 首歌曲加入歌单...`); + window.openPlaylistAddModal(audios.map(file => this.wmBuildSong(file)).filter(Boolean)); + }, + toggleUnindexed() { const el = document.getElementById('lm-unindexed-filter'); this.filterUnindexed = el.checked; @@ -1609,7 +1904,9 @@ window.LocalMusicManager = { ? '' : (item.folder === 'openlist' || item.openlist) ? '' - : ''; + : (item.folder === 'webdav' || item.webdav) + ? '' + : ''; html += `
    @@ -1896,15 +2193,19 @@ window.LocalMusicManager = { isPlaylistCollectable(item) { if (item.folder === 'openlist' || item.openlist || (item.songInfo && item.songInfo.source === 'openlist')) return true; + if (item.folder === 'webdav' || item.webdav || (item.songInfo && item.songInfo.source === 'webdav')) return true; return !!this.getPlaylistPlatformIdentity(item); }, buildPlaylistSong(item) { const songInfo = item?.songInfo || {}; const isOpenList = item.folder === 'openlist' || item.openlist || songInfo.source === 'openlist'; + const isWebdav = item.folder === 'webdav' || item.webdav || songInfo.source === 'webdav'; const identity = isOpenList ? { source: 'openlist', platformId: String(item?.path || item?.filename || ''), id: `openlist_${encodeURIComponent(item?.path || item?.filename || '')}` } - : this.getPlaylistPlatformIdentity(item); + : isWebdav + ? { source: 'webdav', platformId: String(item?.path || item?.filename || ''), id: `webdav_${encodeURIComponent(item?.path || item?.filename || '')}` } + : this.getPlaylistPlatformIdentity(item); if (!identity) return null; const quality = item?.quality || songInfo.quality || songInfo.type || '128k'; let types = songInfo.types; @@ -1946,6 +2247,15 @@ window.LocalMusicManager = { result.isLocal = true; result.folder = 'openlist'; } + // 保留 WebDAV 播放所需字段(收藏到歌单后仍可恢复播放) + if (isWebdav) { + result.url = item?.url || songInfo.url || ''; + result.serverId = item?.serverId || songInfo.serverId || ''; + result.path = item?.path || item?.filename || ''; + result.webdav = true; + result.isLocal = true; + result.folder = 'webdav'; + } return result; }, @@ -1980,12 +2290,12 @@ window.LocalMusicManager = { // 将当前目录(OpenList 目录或下载目录子路径)下的歌曲一键保存为歌单 async addCurrentDirToPlaylist() { const folder = this.filterFolder; - if (folder !== 'openlist' && folder !== 'music') { - if (typeof showInfo === 'function') showInfo('请先在筛选中选择“OpenList”或“下载”目录'); + if (folder !== 'openlist' && folder !== 'webdav' && folder !== 'music') { + if (typeof showInfo === 'function') showInfo('请先在筛选中选择"OpenList"、"WebDAV"或"下载"目录'); return; } const targetSubPath = this.selectedSubPath; - const dirLabel = targetSubPath === '' ? (folder === 'openlist' ? 'OpenList 全部' : '下载根目录') + const dirLabel = targetSubPath === '' ? (folder === 'openlist' ? 'OpenList 全部' : folder === 'webdav' ? 'WebDAV 全部' : '下载根目录') : targetSubPath === '__ROOT__' ? '根目录' : targetSubPath; @@ -1993,6 +2303,8 @@ window.LocalMusicManager = { const targets = this.originalData.filter(item => { if (folder === 'openlist') { if (item.folder !== 'openlist' && !item.openlist) return false; + } else if (folder === 'webdav') { + if (item.folder !== 'webdav' && !item.webdav) return false; } else { if (item.folder !== 'music') return false; } @@ -2031,12 +2343,16 @@ window.LocalMusicManager = { const username = (window.currentListData && window.currentListData.username) || localStorage.getItem('lx_sync_user') || '_open'; const authToken = (window.getUserAuthHeaders ? window.getUserAuthHeaders()['x-user-token'] : null) || localStorage.getItem('lx_user_token') || ''; - // OpenList 条目使用其 stream URL(含 server/path/sign),本地缓存条目使用 cache/file URL + // OpenList 条目使用其 stream URL(含 server/path/sign),WebDAV 条目使用挂载 stream URL,本地缓存条目使用 cache/file URL const isOpenList = item.folder === 'openlist' || item.openlist; + const isWebdav = item.folder === 'webdav' || item.webdav || item.songInfo?.source === 'webdav'; const buildLocalUrl = (d) => { if (isOpenList && d.openlist) { return d.url + (authToken && d.url && !d.url.includes('token=') ? `${d.url.includes('?') ? '&' : '?'}token=${encodeURIComponent(authToken)}` : ''); } + if (isWebdav && (d.webdav || d.url)) { + return d.url || `/api/music/cache/file/${encodeURIComponent(username)}/${encodeURIComponent(d.filename)}?folder=${d.folder}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`; + } return `/api/music/cache/file/${encodeURIComponent(username)}/${encodeURIComponent(d.filename)}?folder=${d.folder}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`; }; @@ -2044,7 +2360,7 @@ window.LocalMusicManager = { ...item.songInfo, // Reconstruct full URL locally url: buildLocalUrl(item), - pic: isOpenList ? '' : `/api/music/cache/cover?filename=${encodeURIComponent(item.filename)}&user=${encodeURIComponent(username)}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`, + pic: (isOpenList || isWebdav) ? '' : `/api/music/cache/cover?filename=${encodeURIComponent(item.filename)}&user=${encodeURIComponent(username)}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`, isLocal: true, folder: item.folder }; @@ -2056,13 +2372,20 @@ window.LocalMusicManager = { songInfo.openlist = true; songInfo.source = 'openlist'; } + // 保留 WebDAV 播放所需的 serverId/path 字段 + if (isWebdav) { + songInfo.serverId = item.serverId; + songInfo.path = item.path; + songInfo.webdav = true; + songInfo.source = 'webdav'; + } // If 'app.js' exposes playSong(song), we use it. // We might want to construct a playlist of local tracks. const playlist = this.displayData.map(d => ({ ...d.songInfo, url: buildLocalUrl(d), - pic: (d.folder === 'openlist' || d.openlist) ? '' : `/api/music/cache/cover?filename=${encodeURIComponent(d.filename)}&user=${encodeURIComponent(username)}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`, + pic: (d.folder === 'openlist' || d.openlist || d.folder === 'webdav' || d.webdav) ? '' : `/api/music/cache/cover?filename=${encodeURIComponent(d.filename)}&user=${encodeURIComponent(username)}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`, isLocal: true })); diff --git a/src/server/server.ts b/src/server/server.ts index abfd62ce..7bfc0994 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -30,6 +30,7 @@ import needle from 'needle' const { MusicTagger, MetaPicture } = require('music-tag-native') import * as cards from './cards' import * as openlist from './openlist' +import * as webdavMount from './webdavMount' // ===== Player Session Store ===== const playerSessions = new Map() @@ -3152,12 +3153,16 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro const mergeOpenList = global.lx.config['user.enableOpenListInLocalMusic'] !== false if (mergeOpenList) { return openlist.getAllLocalIndex().then(openListFiles => { - const merged = openListFiles.length ? [...list, ...openListFiles] : list - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache, no-store, must-revalidate', + const withOpenList = openListFiles.length ? [...list, ...openListFiles] : list + // [新增] 整合 WebDAV 挂载:把挂载源扫描的音频文件合并进本地音乐列表 + return webdavMount.getAllLocalIndex().then(webdavFiles => { + const merged = webdavFiles.length ? [...withOpenList, ...webdavFiles] : withOpenList + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }) + res.end(JSON.stringify({ success: true, data: merged })) }) - res.end(JSON.stringify({ success: true, data: merged })) }) } res.writeHead(200, { @@ -4930,6 +4935,386 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro return } + // ===== WebDAV 音乐挂载:管理路由 ===== + + // 挂载源列表(管理员,密码脱敏) + if (pathname === '/api/webdav-mounts' && req.method === 'GET') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const mounts = webdavMount.listMounts().map((m: any) => ({ + id: m.id, + name: m.name, + baseUrl: m.baseUrl, + username: m.username, + hasPassword: !!m.password, + rootPath: m.rootPath, + enabled: m.enabled, + createdAt: m.createdAt, + })) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, mounts })) + return + } + + // 新增挂载源(管理员) + if (pathname === '/api/webdav-mounts' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const data = JSON.parse(body) + if (!data.baseUrl) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 WebDAV 地址' })) + return + } + const mount = webdavMount.addMount(data) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, mount })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // 更新挂载源(管理员) + if (pathname === '/api/webdav-mounts' && req.method === 'PUT') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const data = JSON.parse(body) + if (!data.id) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少挂载源 ID' })) + return + } + const mount = webdavMount.updateMount(data.id, data) + if (!mount) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '挂载源不存在' })) + return + } + webdavMount.clearLocalIndex(data.id) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, mount })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // 删除挂载源(管理员,同时清理缓存) + if (pathname === '/api/webdav-mounts' && req.method === 'DELETE') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const { id } = JSON.parse(body) + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少挂载源 ID' })) + return + } + const ok = webdavMount.deleteMount(id) + webdavMount.clearLocalIndex(id) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, deleted: ok })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // 测试挂载源连接(管理员) + if (pathname === '/api/webdav-mounts/test' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const { id } = JSON.parse(body) + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少挂载源 ID' })) + return + } + webdavMount.testConnection(id).then(result => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: result.ok, message: result.message })) + }).catch((err: any) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // 可用的挂载源列表(登录用户/管理员,用于播放器选择) + if (pathname === '/api/webdav-mounts/available' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const mounts = webdavMount.listMounts() + .filter((m: any) => m.enabled) + .map((m: any) => ({ + id: m.id, + name: m.name, + baseUrl: m.baseUrl, + rootPath: m.rootPath, + hasAuth: !!(m.username && m.password), + })) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, mounts })) + return + } + + // 目录浏览(登录用户/管理员) + if (pathname === '/api/webdav-mounts/browse' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const mountId = urlObj.searchParams.get('server') || urlObj.searchParams.get('id') || '' + const dirPath = urlObj.searchParams.get('path') || '/' + webdavMount.browse(mountId, dirPath).then(result => { + res.writeHead(result.success ? 200 : 404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(result)) + }) + return + } + + // 音频索引(单挂载或全部合并) + if (pathname === '/api/webdav-mounts/local-list' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const mountId = urlObj.searchParams.get('server') || '' + const refresh = urlObj.searchParams.get('refresh') === '1' + webdavMount.getLocalIndex(mountId, refresh) + .then(files => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, total: files.length, items: files })) + }) + .catch((e: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message })) + }) + return + } + + // 播放/流式代理(本地缓存优先 + 边播边写) + if (pathname === '/api/webdav-mounts/stream' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const filePath = urlObj.searchParams.get('path') || '' + if (!serverId || !filePath) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 server 或 path 参数' })) + return + } + const mount = webdavMount.getMount(serverId) + if (!mount) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '挂载源不存在' })) + return + } + // 本地缓存优先 + const cacheFilePath = webdavMount.getCacheFilePath(mount, filePath) + if (webdavMount.serveCacheFile(cacheFilePath, req.headers.range as string | undefined, res)) { + return + } + try { + const proxyReq = webdavMount.stream(mount, filePath, req.headers.range as string | undefined) + const httpMod = require('http') + const httpsMod = require('https') + const handleStreamError = (err: any) => { + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + } else { + res.end() + } + } + // 递归跟随 3xx 重定向,最多 5 跳 + const followRedirect = (currentReq: any, hop = 0) => { + currentReq.on('error', handleStreamError) + currentReq.on('response', (resp: any) => { + const statusCode = resp.statusCode || 200 + const location = resp.headers['location'] + if (hop < 5 && statusCode >= 300 && statusCode < 400 && location) { + resp.resume() + let targetUrl: URL + try { + targetUrl = new URL(location) + } catch (e) { + handleStreamError(new Error('非法重定向地址')) + return + } + const lib = targetUrl.protocol === 'https:' ? httpsMod : httpMod + const redirectHeaders: Record = {} + const rangeHeader = String(req.headers.range || '') + if (rangeHeader) redirectHeaders['Range'] = rangeHeader + const nextReq = lib.request(targetUrl, { method: 'GET', headers: redirectHeaders } as any) + nextReq.on('error', () => { /* 下一跳处理 */ }) + nextReq.end() + followRedirect(nextReq, hop + 1) + return + } + const outHeaders: Record = {} + if (resp.headers['content-type']) outHeaders['Content-Type'] = String(resp.headers['content-type']).split(';')[0] || 'audio/mpeg' + if (resp.headers['content-length']) outHeaders['Content-Length'] = resp.headers['content-length'] + if (resp.headers['accept-ranges']) outHeaders['Accept-Ranges'] = resp.headers['accept-ranges'] + if (resp.headers['content-range']) outHeaders['Content-Range'] = resp.headers['content-range'] + outHeaders['Cache-Control'] = 'no-cache' + res.writeHead(statusCode, outHeaders) + + // 边播边缓存:仅从头开始请求时写入 + const rangeHeader = String(req.headers.range || '') + const isFullRange = !rangeHeader || rangeHeader === 'bytes=0-' || rangeHeader === 'bytes=0' + if (isFullRange) { + const tmpPath = cacheFilePath + '.tmp' + const cacheWs = fs.createWriteStream(tmpPath, { flags: 'w' }) + let cacheReceived = 0 + const total = parseInt(resp.headers['content-length'] || '0', 10) + webdavMount.trackCacheProgress(mount.id, filePath, total, 0) + resp.on('data', (chunk: any) => { + cacheReceived += chunk.length + cacheWs.write(chunk) + webdavMount.trackCacheProgress(mount.id, filePath, total, cacheReceived) + }) + resp.on('end', () => { + cacheWs.end(() => { + if (total === 0 || cacheReceived >= total) { + fs.rename(tmpPath, cacheFilePath, (err: any) => { + if (err) fs.unlink(tmpPath, () => { }) + webdavMount.markCacheDone(mount.id, filePath) + }) + } else { + fs.unlink(tmpPath, () => { }) + webdavMount.clearCacheProgress(mount.id, filePath) + } + }) + }) + resp.on('error', () => { + cacheWs.destroy() + fs.unlink(tmpPath, () => { }) + webdavMount.clearCacheProgress(mount.id, filePath) + }) + res.on('close', () => { + if (!resp.complete) { + cacheWs.destroy() + fs.unlink(tmpPath, () => { }) + webdavMount.clearCacheProgress(mount.id, filePath) + } + }) + } + resp.pipe(res) + }) + } + followRedirect(proxyReq) + req.on('close', () => { + if (!proxyReq.destroyed) proxyReq.destroy() + }) + } catch (err: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + } + return + } + + // 单文件缓存状态(登录用户/管理员) + if (pathname === '/api/webdav-mounts/cache/check' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const filePath = urlObj.searchParams.get('path') || '' + const mount = webdavMount.getMount(serverId) + if (!mount || !filePath) { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, cached: false })) + return + } + const cached = webdavMount.isFileCached(mount, filePath) + const progress = webdavMount.getCacheProgress(mount.id, filePath) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + success: true, + cached, + progress: progress ? { total: progress.total, received: progress.received, done: progress.done } : null, + })) + return + } + + // 缓存汇总(登录用户/管理员) + if (pathname === '/api/webdav-mounts/cache/status' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const mountId = urlObj.searchParams.get('server') || undefined + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, ...webdavMount.cacheStatus(mountId) })) + return + } + + // 清空缓存(管理员) + if (pathname === '/api/webdav-mounts/cache/clear' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const mountId = urlObj.searchParams.get('server') || undefined + webdavMount.clearCache(mountId) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true })) + return + } + // [新增] OpenList 文件下载(登录用户/管理员) if (pathname === '/api/openlist/download' && req.method === 'GET') { const username = requirePlayerOrAdmin() diff --git a/src/server/subsonic.ts b/src/server/subsonic.ts index 76a5a672..6bcf5719 100644 --- a/src/server/subsonic.ts +++ b/src/server/subsonic.ts @@ -3,6 +3,8 @@ import crypto from 'crypto' import { URL } from 'url' import { getUserSpace, getUserDirname } from '@/user' import { callUserApiGetMusicUrl } from '@/server/userApi' +import * as webdavMount from '@/server/webdavMount' +import * as openlist from '@/server/openlist' import { getSingerPic, getSingerDetail, getSingerMid } from '@/server/utils/singer' import { fetchRecommendedAlbums } from '@/server/utils/recommendAlbums' import { fetchGenres, fetchRadios, fetchPlaylistsByGenre, fetchRadioSongs, fetchPlaylistSongs, fetchSongsByGenre } from '@/server/utils/discovery' @@ -550,6 +552,66 @@ class SubsonicHandler { return null } + /** + * 解析本地挂载歌曲的内部流 URL(webdav/openlist/local) + * - webdav_ 前缀: songmid 为 encodeURIComponent 后的远程路径,从 webdav 挂载索引匹配 + * - openlist_ 前缀: songmid 同理,从 openlist 服务器索引匹配 + * - local: 本地缓存文件直接由 /api/music/cache/file 服务 + */ + private async resolveLocalStreamUrl(username: string, source: string, songmid: string, id: string): Promise { + const serverPath = songmid.replace(/\+/g, ' ') + + if (source === 'webdav') { + let filePath = '' + try { + filePath = decodeURIComponent(serverPath) + } catch (e) { + filePath = serverPath + } + const items = await webdavMount.getAllLocalIndex() + const hit = items.find((it: any) => it.id === id || it.songmid === songmid || it.path === filePath || it.filename === filePath) + if (hit && hit.serverId) { + const p = encodeURIComponent(hit.path || filePath) + return `/api/webdav-mounts/stream?server=${encodeURIComponent(hit.serverId)}&path=${p}` + } + return null + } + + if (source === 'openlist') { + let filePath = '' + try { + filePath = decodeURIComponent(serverPath) + } catch (e) { + filePath = serverPath + } + const items = await openlist.getAllLocalIndex() + const hit = items.find((it: any) => it.id === id || it.songmid === songmid || it.path === filePath || it.filename === filePath) + if (hit && hit.serverId) { + const p = encodeURIComponent(hit.path || filePath) + let url = `/api/openlist/stream?server=${encodeURIComponent(hit.serverId)}&path=${p}` + if (hit.sign) url += `&sign=${encodeURIComponent(hit.sign)}` + return url + } + return null + } + + // local: 优先走内部缓存文件服务 + try { + const userDir = getUserDirname(username) + const fileName = songmid.split('/').pop() || '' + const cacheRoot = path.join(userDir, 'music', 'cache') + if (fs.existsSync(cacheRoot)) { + const files = fs.readdirSync(cacheRoot) + const matched = files.find((f: string) => f === fileName || f.startsWith(fileName)) + if (matched) { + return `/api/music/cache/file/${encodeURIComponent(username)}/${encodeURIComponent(matched)}` + } + } + } catch (e) { } + + return null + } + // ───────────────────────────────────────────── // 端点实现 // ───────────────────────────────────────────── @@ -1974,6 +2036,20 @@ class SubsonicHandler { songmid = id } + // [本地挂载] webdav_/openlist_/local 走内部流 302,由内部流路由统一负责「缓存优先 + 边播边写」 + if (source === 'webdav' || source === 'openlist' || source === 'local') { + try { + const internalUrl = await this.resolveLocalStreamUrl(username, source, songmid, id) + if (internalUrl) { + res.writeHead(302, { Location: internalUrl }) + return res.end() + } + } catch (e: any) { + console.error(`[Subsonic] resolve local stream failed: ${id}`, e?.message || e) + } + return this.sendError(res, 0, 'Could not resolve local track', format) + } + try { const maxBitrate = parseInt(params.get('maxBitrate') || '0') let quality = '128k' diff --git a/src/server/webdavMount.test.ts b/src/server/webdavMount.test.ts new file mode 100644 index 00000000..32960dbd --- /dev/null +++ b/src/server/webdavMount.test.ts @@ -0,0 +1,370 @@ +import { test, describe } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import http from 'node:http' +import crypto from 'node:crypto' + +const tmpData = fs.mkdtempSync(path.join(os.tmpdir(), 'wdmount-test2-')) +;(global as any).lx = { dataPath: tmpData } + +import * as wm from './webdavMount' + +// ===== 简易 WebDAV mock 服务器 ===== +// 目录结构: +// / (root) +// music/ (dir) +// a.mp3 (file, 1024) +// b.flac (file, 2048) +// sub/ (dir) +// c.ogg (file, 512) +// notes.txt (file, 100) +const tree: Record> = { + '': [ + { name: 'music', type: 'directory', size: 0 }, + { name: 'notes.txt', type: 'file', size: 100 }, + ], + '/music': [ + { name: 'a.mp3', type: 'file', size: 1024 }, + { name: 'b.flac', type: 'file', size: 2048 }, + { name: 'sub', type: 'directory', size: 0 }, + ], + '/music/sub': [ + { name: 'c.ogg', type: 'file', size: 512 }, + ], +} + +// 文件内容生成:按路径确定性生成,便于验证 Range +const fileContent = (filePath: string, size: number): Buffer => { + const buf = Buffer.alloc(size) + for (let i = 0; i < size; i++) buf[i] = (filePath.charCodeAt(i % filePath.length) + i) % 256 + return buf +} + +const parseDavPath = (urlPath: string): string => { + const u = new URL(urlPath, 'http://localhost') + return decodeURIComponent(u.pathname).replace(new RegExp('^/dav'), '') +} + +const server = http.createServer((req, res) => { + const davPath = parseDavPath(req.url || '/') + res.setHeader('Content-Type', 'application/xml') + const dir = (davPath === '/' ? '' : davPath).replace(/\/+$/, '') + const list = tree[dir] + const effectivePath = dir === '' ? '/' : dir + if (req.method === 'GET') { + // GET 返回文件内容(支持 Range) + const name = dir.split('/').pop() || '' + const parent = dir.substring(0, dir.lastIndexOf('/')) + const parentDir = parent === '' ? '/' : parent + const base = tree[parentDir] || [] + const entry = base.find(it => it.name === name && it.type === 'file') + if (!entry) { res.statusCode = 404; res.end(''); return } + const content = fileContent('/dav' + dir, entry.size) + const range = req.headers.range as string | undefined + if (range) { + const m = /bytes=(\d*)-(\d*)/.exec(range) + const start = m && m[1] ? parseInt(m[1], 10) : 0 + const end = m && m[2] ? parseInt(m[2], 10) : entry.size - 1 + const chunk = content.subarray(start, end + 1) + res.writeHead(206, { + 'Content-Type': 'audio/mpeg', + 'Content-Range': `bytes ${start}-${end}/${entry.size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunk.length, + }) + res.end(chunk) + } else { + res.writeHead(200, { + 'Content-Type': 'audio/mpeg', + 'Accept-Ranges': 'bytes', + 'Content-Length': entry.size, + }) + res.end(content) + } + return + } + if (req.method === 'PROPFIND' && list) { + const entries = list.map((it, i) => { + const href = effectivePath.replace(/\/$/, '') + '/' + it.name + const isDir = it.type === 'directory' + return `/dav${encodeURI(href)}${isDir ? '' : ''}${it.size}${new Date(1700000000000 + i).toUTCString()}${it.name}HTTP/1.1 200 OK` + }) + res.end(`${encodeURI(effectivePath)}HTTP/1.1 200 OK${entries.join('')}`) + return + } + res.statusCode = 404 + res.end('') +}) + +let port = 0 +const listen = (): Promise => new Promise(resolve => { + server.listen(0, '127.0.0.1', () => resolve((server.address() as any).port)) +}) + +describe('webdavMount 基础框架', () => { + test('CRUD: 新增/查询/更新/删除挂载源', () => { + const m = wm.addMount({ + name: '测试WebDAV', + baseUrl: 'alist.example.com/dav/音乐', + username: 'user1', + password: 'secret', + rootPath: '/', + }) + assert.ok(m.id) + assert.ok(m.id.startsWith('wd_')) + assert.equal(m.baseUrl, 'http://alist.example.com/dav/音乐', '无协议应补 http://') + assert.equal(m.enabled, true) + + const got = wm.getMount(m.id) + assert.ok(got) + assert.equal(got.name, '测试WebDAV') + + const updated = wm.updateMount(m.id, { name: '改名' }) + assert.ok(updated) + assert.equal(wm.getMount(m.id)!.name, '改名') + + assert.ok(wm.deleteMount(m.id)) + assert.equal(wm.getMount(m.id), null) + }) + + test('删除挂载源会清理其缓存目录', () => { + const m = wm.addMount({ name: '缓存清理', baseUrl: 'http://x.example.com' }) + const cacheDir = path.join(tmpData, 'webdav-cache', m.id) + fs.mkdirSync(cacheDir, { recursive: true }) + fs.writeFileSync(path.join(cacheDir, 'a.mp3'), 'x') + assert.ok(fs.existsSync(cacheDir)) + assert.ok(wm.deleteMount(m.id)) + assert.equal(fs.existsSync(cacheDir), false, '删除挂载源应清理缓存目录') + }) + + test('缺 baseUrl 新增应抛错', () => { + assert.throws(() => wm.addMount({ name: 'no-url' }), /缺少 WebDAV 地址/) + }) + + test('持久化到 webdav-mounts.json', () => { + wm.addMount({ name: '持久化', baseUrl: 'http://persist.example.com', username: 'u', password: 'p' }) + const file = path.join(tmpData, 'webdav-mounts.json') + assert.ok(fs.existsSync(file)) + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) + assert.ok(Array.isArray(parsed.mounts)) + assert.ok(parsed.mounts.length >= 1) + assert.ok(parsed.mounts.some((m: any) => m.name === '持久化')) + }) + + test('浏览不存在的挂载源返回错误', async () => { + const res = await wm.listFiles({ id: 'nope', name: 'x', baseUrl: 'http://127.0.0.1:1', username: '', password: '', rootPath: '/', enabled: true, createdAt: 0 } as any, '/', 1000) + assert.ok(res.items.length === 0) + assert.ok(res.error, '应返回连接错误信息') + }) + + test('testConnection: 不存在的挂载源返回失败', async () => { + const res = await wm.testConnection('not-exist-id') + assert.equal(res.ok, false) + assert.match(res.message, /挂载源不存在/) + }) + + test('testConnection: 无法连接返回失败信息', async () => { + const m = wm.addMount({ name: '无法连接', baseUrl: 'http://127.0.0.1:1' }) + const res = await wm.testConnection(m.id) + assert.equal(res.ok, false) + assert.ok(res.message) + }) +}) + +describe('webdavMount 音频索引', () => { + test('browse 返回目录与文件列表', async () => { + port = await listen() + const m = wm.addMount({ name: 'mock', baseUrl: `http://127.0.0.1:${port}/dav`, rootPath: '/' }) + const res = await wm.browse(m.id, '/') + assert.equal(res.success, true) + assert.ok(res.items) + const names = res.items!.map(i => i.name) + assert.ok(names.includes('music')) + assert.ok(names.includes('notes.txt')) + const music = res.items!.find(i => i.name === 'music') + assert.equal(music!.isDir, true) + }) + + test('collectAudioFiles 递归收集全部音频(映射为 webdav 条目)', async () => { + const m = wm.addMount({ name: 'mock2', baseUrl: `http://127.0.0.1:${port}/dav`, rootPath: '/' }) + const files = await wm.getLocalIndex(m.id, true) + assert.ok(Array.isArray(files)) + assert.equal(files.length, 3, '应收集 3 个音频文件(music/a.mp3, b.flac, sub/c.ogg)') + + const a = files.find((f: any) => f.name === 'a') + assert.ok(a, '应包含 a.mp3 的条目') + assert.equal(a.source, 'webdav') + assert.equal(a.folder, 'webdav') + assert.equal(a.serverId, m.id) + assert.equal(a.path, '/music/a.mp3') + assert.equal(a.size, 1024) + assert.ok(a.url.startsWith('/api/webdav-mounts/stream?server=')) + assert.ok(a.url.includes(encodeURIComponent('/music/a.mp3'))) + assert.ok(a.id.startsWith('webdav_')) + + const c = files.find((f: any) => f.name === 'c') + assert.ok(c, '应递归到子目录 sub 收集 c.ogg') + assert.equal(c.subPath, '/music/sub') + }) + + test('local-index 缓存: 第二次调用不重新扫描(TTL 内直接返回)', async () => { + const m = wm.addMount({ name: 'mock3', baseUrl: `http://127.0.0.1:${port}/dav`, rootPath: '/' }) + const first = await wm.getLocalIndex(m.id, true) + const second = await wm.getLocalIndex(m.id) + assert.equal(second.length, first.length) + }) + + test('getAllLocalIndex 合并全部启用挂载源', async () => { + const merged = await wm.getAllLocalIndex() + assert.ok(Array.isArray(merged)) + }) + + test('clearLocalIndex 清空索引缓存', async () => { + const m = wm.addMount({ name: 'mock4', baseUrl: `http://127.0.0.1:${port}/dav`, rootPath: '/' }) + await wm.getLocalIndex(m.id, true) + wm.clearLocalIndex(m.id) + const files = await wm.getLocalIndex(m.id) + assert.ok(Array.isArray(files)) + }) + + test('禁用或缺失挂载源返回空索引', async () => { + const files = await wm.getLocalIndex('not-exist') + assert.deepEqual(files, []) + const m = wm.addMount({ name: 'disabled', baseUrl: `http://127.0.0.1:${port}/dav`, rootPath: '/', enabled: false }) + const files2 = await wm.getLocalIndex(m.id) + assert.deepEqual(files2, []) + }) +}) + +describe('webdavMount 边播边缓存', () => { + test('缓存路径: hash 命名保留扩展名, 同一文件幂等', () => { + const m = { id: 'wd_1', name: 'x', baseUrl: 'http://x', username: '', password: '', rootPath: '/', enabled: true, createdAt: 0 } as any + const p1 = wm.getCacheFilePath(m, '/music/a.mp3') + const p2 = wm.getCacheFilePath(m, '/music/a.mp3') + assert.equal(p1, p2) + assert.ok(p1.endsWith('.mp3')) + assert.ok(p1.includes(path.join('webdav-cache', 'wd_1'))) + const p3 = wm.getCacheFilePath(m, '/music/b.flac') + assert.ok(p3.endsWith('.flac')) + assert.notEqual(p1, p3, '不同文件 hash 不同') + }) + + test('downloadToCache 完整下载落盘并支持后续缓存命中', async () => { + const m = wm.addMount({ name: 'cache-mock', baseUrl: `http://127.0.0.1:${port}/dav`, rootPath: '/' }) + const filePath = '/music/a.mp3' + assert.equal(wm.isFileCached(m, filePath), false) + const ok = await wm.downloadToCache(m, filePath) + assert.equal(ok, true) + assert.equal(wm.isFileCached(m, filePath), true) + + const cacheFile = wm.getCacheFilePath(m, filePath) + const stat = fs.statSync(cacheFile) + assert.equal(stat.size, 1024, '缓存文件大小应与源一致') + + const progress = wm.getCacheProgress(m.id, filePath) + assert.ok(progress) + assert.equal(progress.done, true) + assert.equal(progress.received, 1024) + }) + + test('downloadToCache 单飞去重: 并发调用只下载一次', async () => { + const m = wm.addMount({ name: 'dedup-mock', baseUrl: `http://127.0.0.1:${port}/dav`, rootPath: '/' }) + const filePath = '/music/b.flac' + const [r1, r2, r3] = await Promise.all([ + wm.downloadToCache(m, filePath), + wm.downloadToCache(m, filePath), + wm.downloadToCache(m, filePath), + ]) + assert.deepEqual([r1, r2, r3], [true, true, true]) + assert.equal(wm.isFileCached(m, filePath), true) + }) + + test('serveCacheFile: 完整与 Range 响应', async () => { + const m = wm.addMount({ name: 'serve-mock', baseUrl: `http://127.0.0.1:${port}/dav`, rootPath: '/' }) + await wm.downloadToCache(m, '/music/a.mp3') + const cacheFile = wm.getCacheFilePath(m, '/music/a.mp3') + + // 完整响应 + let captured: any = null + const fakeResFull: any = new (class { + writeHead(code: number, headers: any) { captured = { code, headers } } + write(chunk: any, cb?: any) { if (typeof cb === 'function') cb(); return true } + end() { } + on() { } + once() { } + emit() { } + destroy() { } + })() + const served = wm.serveCacheFile(cacheFile, undefined, fakeResFull) + assert.equal(served, true) + assert.equal(captured.code, 200) + assert.equal(captured.headers['Content-Length'], 1024) + assert.equal(captured.headers['Accept-Ranges'], 'bytes') + + // Range 响应 + let capturedRange: any = null + const fakeResRange: any = new (class { + writeHead(code: number, headers: any) { capturedRange = { code, headers } } + write(chunk: any, cb?: any) { if (typeof cb === 'function') cb(); return true } + end() { } + on() { } + once() { } + emit() { } + destroy() { } + })() + wm.serveCacheFile(cacheFile, 'bytes=0-99', fakeResRange) + assert.equal(capturedRange.code, 206) + assert.equal(capturedRange.headers['Content-Length'], 100) + assert.equal(capturedRange.headers['Content-Range'], 'bytes 0-99/1024') + }) + + test('serveCacheFile: 无效 Range 返回 416, 不存在的文件返回 false', () => { + let captured: any = null + const fakeRes: any = { writeHead: (code: number, headers: any) => { captured = { code, headers } }, end: () => { } } + const served = wm.serveCacheFile('/nonexistent', undefined, fakeRes) + assert.equal(served, false) + + // 超出文件大小的 range + const tmp = path.join(tmpData, 'tiny.mp3') + fs.writeFileSync(tmp, Buffer.alloc(50)) + wm.serveCacheFile(tmp, 'bytes=500-600', fakeRes) + assert.equal(captured.code, 416) + }) + + test('stream: 原生代理 GET 返回文件流(带 Range)', async () => { + const m = wm.addMount({ name: 'stream-mock', baseUrl: `http://127.0.0.1:${port}/dav`, rootPath: '/' }) + const data = await new Promise<{ status: number; body: Buffer }>((resolve, reject) => { + const proxyReq = wm.stream(m, '/music/a.mp3', 'bytes=0-31') + proxyReq.on('error', reject) + proxyReq.on('response', (resp: any) => { + const chunks: Buffer[] = [] + resp.on('data', (c: Buffer) => chunks.push(c)) + resp.on('end', () => resolve({ status: resp.statusCode, body: Buffer.concat(chunks) })) + }) + }) + assert.equal(data.status, 206) + assert.equal(data.body.length, 32, 'Range 0-31 应为 32 字节') + }) + + test('cacheStatus / clearCache: 汇总与清空', async () => { + const m = wm.addMount({ name: 'status-mock', baseUrl: `http://127.0.0.1:${port}/dav`, rootPath: '/' }) + await wm.downloadToCache(m, '/music/a.mp3') + const st = wm.cacheStatus(m.id) + assert.equal(st.fileCount, 1) + assert.equal(st.size, 1024) + + wm.clearCache(m.id) + const st2 = wm.cacheStatus(m.id) + assert.equal(st2.fileCount, 0) + assert.equal(wm.isFileCached(m, '/music/a.mp3'), false) + }) +}) + +import { after } from 'node:test' +after(() => { + server.close() + try { fs.rmSync(tmpData, { recursive: true, force: true }) } catch (e) { /* ignore */ } +}) +void crypto diff --git a/src/server/webdavMount.ts b/src/server/webdavMount.ts new file mode 100644 index 00000000..46cd1aca --- /dev/null +++ b/src/server/webdavMount.ts @@ -0,0 +1,517 @@ +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import * as http from 'http' +import * as https from 'https' + +const CONFIG_FILE = 'webdav-mounts.json' + +export interface WebDAVMount { + id: string + name: string + baseUrl: string + username: string + password: string + rootPath: string + enabled: boolean + createdAt: number +} + +interface WebDAVConfig { + mounts: WebDAVMount[] +} + +const defaultConfig: WebDAVConfig = { mounts: [] } + +let config: WebDAVConfig = { mounts: [] } + +const configPath = (): string => path.join(global.lx.dataPath, CONFIG_FILE) + +const now = (): number => Date.now() + +const normalizeWebdavUrl = (url?: string): string => { + const trimmed = (url || '').trim() + if (!trimmed) return '' + if (/^https?:\/\//i.test(trimmed)) return trimmed + return 'http://' + trimmed +} + +export const loadConfig = (): WebDAVConfig => { + const p = configPath() + if (fs.existsSync(p)) { + try { + const parsed = JSON.parse(fs.readFileSync(p, 'utf8')) + config = { mounts: Array.isArray(parsed.mounts) ? parsed.mounts : [] } + } catch (e) { + config = { mounts: [] } + } + } + return config +} + +export const saveConfig = (): void => { + try { + fs.writeFileSync(configPath(), JSON.stringify(config, null, 2), 'utf8') + } catch (e) { + console.error('[WebDAVMount] Failed to save config:', e) + } +} + +export const listMounts = (): WebDAVMount[] => { + loadConfig() + return config.mounts.slice().sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)) +} + +export const getMount = (id: string): WebDAVMount | null => { + loadConfig() + return config.mounts.find(m => m.id === id) || null +} + +const normalizeMount = (data: any): WebDAVMount => { + let baseUrl = String(data.baseUrl || '').trim().replace(/\/+$/, '') + if (baseUrl && !/^https?:\/\//i.test(baseUrl)) baseUrl = 'http://' + baseUrl + return { + id: data.id || 'wd_' + crypto.randomBytes(6).toString('hex'), + name: String(data.name || 'WebDAV').trim(), + baseUrl, + username: String(data.username || '').trim(), + password: String(data.password || ''), + rootPath: String(data.rootPath || '/').trim() || '/', + enabled: data.enabled !== false, + createdAt: data.createdAt || now(), + } +} + +export const addMount = (data: any): WebDAVMount => { + loadConfig() + if (!data.baseUrl) throw new Error('缺少 WebDAV 地址') + const mount = normalizeMount(data) + config.mounts.push(mount) + saveConfig() + return mount +} + +export const updateMount = (id: string, data: any): WebDAVMount | null => { + loadConfig() + const idx = config.mounts.findIndex(m => m.id === id) + if (idx < 0) return null + const merged = normalizeMount({ ...config.mounts[idx], ...data, id }) + config.mounts[idx] = merged + saveConfig() + return merged +} + +export const deleteMount = (id: string): boolean => { + loadConfig() + const before = config.mounts.length + config.mounts = config.mounts.filter(m => m.id !== id) + saveConfig() + if (config.mounts.length < before) { + try { + const cacheDir = path.join(global.lx.dataPath, 'webdav-cache', id) + if (fs.existsSync(cacheDir)) fs.rmSync(cacheDir, { recursive: true, force: true }) + } catch (e) { + console.error('[WebDAVMount] Failed to clean cache dir:', e) + } + } + return config.mounts.length < before +} + +/** + * 动态创建 webdav 客户端(ESM 模块) + */ +export const initClient = async (mount: WebDAVMount, force = false): Promise => { + const { createClient } = await import('webdav') + const options: any = {} + if (mount.username) options.username = mount.username + if (mount.password) options.password = mount.password + return createClient(normalizeWebdavUrl(mount.baseUrl), options) +} + +const joinRemotePath = (rootPath: string, dirPath: string): string => { + const root = rootPath === '/' ? '' : rootPath + const dir = dirPath === '/' ? '' : dirPath + const full = (root + '/' + dir).replace(/\/{2,}/g, '/') + return full || '/' +} + +/** + * 列出目录内容(带超时),返回 { items, error? } + */ +export const listFiles = async (mount: WebDAVMount, dirPath: string, timeoutMs = 20000): Promise<{ items: Array<{ name: string; isDir: boolean; size: number; mtime: number }>; error?: string }> => { + try { + const client = await initClient(mount) + const stats: any[] = await Promise.race([ + client.getDirectoryContents(joinRemotePath(mount.rootPath, dirPath)), + new Promise((resolve) => setTimeout(() => resolve([]), timeoutMs)), + ]) + const items = (stats || []).map((it: any) => ({ + name: it.basename || path.posix.basename(String(it.filename || '')).split('/').pop() || '', + isDir: it.type === 'directory', + size: it.size || 0, + mtime: Date.parse(String(it.lastmod || '')) || 0, + })).filter(it => it.name && it.name !== '.' && it.name !== '..') + return { items } + } catch (e: any) { + return { items: [], error: e.message || '连接失败' } + } +} + +/** + * 测试连接:列出 rootPath 验证连通 + */ +export const testConnection = async (id: string): Promise<{ ok: boolean; message: string }> => { + const mount = getMount(id) + if (!mount) return { ok: false, message: '挂载源不存在' } + const { items, error } = await listFiles(mount, mount.rootPath === '/' ? '/' : '', 20000) + if (error) return { ok: false, message: error } + return { ok: true, message: `连接成功,共 ${items.length} 项` } +} + +/** + * 浏览目录:返回子目录与文件列表(供前端目录树) + */ +export const browse = async (mountId: string, dirPath: string): Promise<{ success: boolean; items?: Array<{ name: string; isDir: boolean; size: number; mtime: number }>; message?: string }> => { + const mount = getMount(mountId) + if (!mount) return { success: false, message: '挂载源不存在' } + const dir = (dirPath || '/').replace(/^\/+/, '') + const { items, error } = await listFiles(mount, dir, 20000) + if (error) return { success: false, message: error } + return { success: true, items } +} + +// ===== 音频索引:递归扫描目录树收集音频文件 ===== + +const AUDIO_EXT_RE = /\.(mp3|flac|wav|ogg|aac|m4a|ape|wma|opus|alac)$/i + +const localIndexCache: Record }> = {} +const LOCAL_INDEX_TTL = 120 * 1000 +const MAX_SCAN_DEPTH = 20 +const MAX_SCAN_FILES = 5000 +const MAX_SCAN_DIRS = 800 +const MAX_SCAN_MS = 60 * 1000 +const SCAN_CONCURRENCY = 6 + +const joinDir = (dirPath: string, name: string): string => { + return (dirPath === '/' ? '' : dirPath) + '/' + name +} + +const collectAudioFiles = async (mount: WebDAVMount, dirPath: string, depth = 0, result: any[] = [], ctx: { dirCount: number; deadline: number } = { dirCount: 0, deadline: Date.now() + MAX_SCAN_MS }): Promise => { + if (depth > MAX_SCAN_DEPTH || result.length >= MAX_SCAN_FILES || ctx.dirCount >= MAX_SCAN_DIRS || Date.now() > ctx.deadline) return result + const dir = dirPath === '/' ? '' : dirPath.replace(/^\/+/, '') + const { items, error } = await listFiles(mount, dir, 20000) + if (error) return result + ctx.dirCount++ + const subDirs: string[] = [] + for (const it of items) { + if (result.length >= MAX_SCAN_FILES || ctx.dirCount >= MAX_SCAN_DIRS || Date.now() > ctx.deadline) break + if (it.isDir) { + subDirs.push(joinDir(dirPath, it.name)) + continue + } + if (!it.name || !AUDIO_EXT_RE.test(it.name)) continue + const ext = (path.extname(it.name) || '.mp3').toLowerCase().slice(1) + const fullPath = joinDir(dirPath, it.name) + const id = `webdav_${encodeURIComponent(fullPath)}` + result.push({ + id, + songmid: id, + songId: id, + name: it.name.replace(/\.[^.]+$/, ''), + singer: '', + album: '', + albumId: '', + source: 'webdav', + downloadSource: 'webdav', + sourceName: mount.name, + quality: ext === 'flac' ? 'flac' : ext, + filename: fullPath, + folder: 'webdav', + subPath: dirPath === '/' ? '' : dirPath, + mtime: it.mtime || Date.now(), + size: it.size || 0, + ext, + hasCover: false, + coverType: 'none', + hasLyric: false, + serverId: mount.id, + path: fullPath, + sign: '', + isLocal: true, + webdav: true, + interval: 0, + url: `/api/webdav-mounts/stream?server=${encodeURIComponent(mount.id)}&path=${encodeURIComponent(fullPath)}`, + }) + } + let idx = 0 + while (idx < subDirs.length) { + const batch = subDirs.slice(idx, idx + SCAN_CONCURRENCY) + idx += SCAN_CONCURRENCY + await Promise.all(batch.map(dir => collectAudioFiles(mount, dir, depth + 1, result, ctx))) + if (Date.now() > ctx.deadline || ctx.dirCount >= MAX_SCAN_DIRS || result.length >= MAX_SCAN_FILES) break + } + return result +} + +/** + * 获取某挂载源的音频索引(带缓存,forceRefresh 强制重新扫描) + */ +export const getLocalIndex = (mountId: string, forceRefresh = false): Promise => { + const mount = getMount(mountId) + if (!mount || !mount.enabled || !mount.baseUrl) return Promise.resolve([]) + const cached = localIndexCache[mountId] + if (!forceRefresh && cached && cached.files && Date.now() - cached.at < LOCAL_INDEX_TTL) { + return Promise.resolve(cached.files) + } + if (!forceRefresh && cached && cached.pending) { + return cached.pending + } + const pending = collectAudioFiles(mount, '/').then(files => { + localIndexCache[mountId] = { files, at: Date.now() } + return files + }) + if (!cached) localIndexCache[mountId] = { files: [], at: 0, pending } + else localIndexCache[mountId] = { ...cached, pending } + return pending +} + +/** + * 获取所有启用挂载源的音频索引(合并) + */ +export const getAllLocalIndex = (forceRefresh = false): Promise => { + const mounts = listMounts().filter(m => m.enabled && m.baseUrl) + return Promise.all(mounts.map(m => getLocalIndex(m.id, forceRefresh))).then(groups => { + const merged: any[] = [] + groups.forEach(group => merged.push(...group)) + return merged + }) +} + +export const clearLocalIndex = (mountId?: string): void => { + if (mountId) delete localIndexCache[mountId] + else Object.keys(localIndexCache).forEach(k => delete localIndexCache[k]) +} + +// ===== 边播边缓存:本地缓存目录 + 流式代理 ===== + +const cacheProgress: Record = {} +const inFlight: Record | undefined> = {} + +const cacheProgressKey = (mountId: string, filePath: string) => mountId + ':' + filePath + +export const getCacheDir = (mount: WebDAVMount): string => { + const dir = path.join(global.lx.dataPath, 'webdav-cache', mount.id) + try { fs.mkdirSync(dir, { recursive: true }) } catch (e) { /* ignore */ } + return dir +} + +export const getCacheFilePath = (mount: WebDAVMount, filePath: string): string => { + const hash = crypto.createHash('md5').update(mount.id + ':' + filePath).digest('hex') + const ext = path.extname(filePath || '').toLowerCase() || '.mp3' + return path.join(getCacheDir(mount), hash + ext) +} + +export const isFileCached = (mount: WebDAVMount, filePath: string): boolean => { + return fs.existsSync(getCacheFilePath(mount, filePath)) +} + +const MIME_TYPES: Record = { + '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg', '.wav': 'audio/wav', + '.ape': 'audio/x-ape', '.opus': 'audio/ogg', '.aac': 'audio/aac', '.wma': 'audio/x-ms-wma', +} + +/** + * 服务本地缓存文件(支持 Range),返回是否已完整缓存 + */ +export const serveCacheFile = (filePath: string, range: string | undefined, res: any): boolean => { + if (!fs.existsSync(filePath)) return false + const stat = fs.statSync(filePath) + const ext = path.extname(filePath).toLowerCase() + const contentType = MIME_TYPES[ext] || 'application/octet-stream' + if (range) { + const parts = range.replace(/bytes=/, '').split('-') + const start = parseInt(parts[0], 10) + const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1 + if (!Number.isFinite(start) || start < 0 || start >= stat.size || (parts[1] && end < start)) { + res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }) + res.end() + return true + } + const chunksize = (end - start) + 1 + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${stat.size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize, + 'Content-Type': contentType, + 'Cache-Control': 'no-cache', + }) + fs.createReadStream(filePath, { start, end }).pipe(res) + return true + } + res.writeHead(200, { + 'Content-Length': stat.size, + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-cache', + }) + fs.createReadStream(filePath).pipe(res) + return true +} + +/** + * 生成 WebDAV 文件 URL(baseUrl + 路径拼接,路径保留编码) + */ +const fileUrl = (mount: WebDAVMount, filePath: string): string => { + const base = normalizeWebdavUrl(mount.baseUrl).replace(/\/+$/, '') + const p = (filePath || '/').replace(/^\/+/, '') + return `${base}/${p.split('/').map(seg => encodeURIComponent(seg)).join('/')}` +} + +/** + * 从 WebDAV 流式读取文件(原生 http/https,支持 Range 与 Basic Auth)。 + * 返回 ClientRequest,通过 'response'/'error' 事件暴露上游响应流。 + */ +export const stream = (mount: WebDAVMount, filePath: string, range?: string): http.ClientRequest => { + const targetUrl = new URL(fileUrl(mount, filePath)) + const headers: Record = { 'User-Agent': 'lxserver/1.0' } + if (mount.username && mount.password) { + const token = Buffer.from(`${mount.username}:${mount.password}`).toString('base64') + headers['Authorization'] = `Basic ${token}` + } + if (range) headers['Range'] = range + const lib = targetUrl.protocol === 'https:' ? https : http + const req = lib.request(targetUrl, { method: 'GET', headers } as any) + req.on('error', () => { /* 错误由调用方处理 */ }) + req.end() + return req +} + +export const getCacheProgress = (mountId: string, filePath: string): { total: number; received: number; done: boolean } | null => { + return cacheProgress[cacheProgressKey(mountId, filePath)] || null +} + +export const trackCacheProgress = (mountId: string, filePath: string, total: number, received: number): void => { + cacheProgress[cacheProgressKey(mountId, filePath)] = { total, received, done: false } +} + +export const markCacheDone = (mountId: string, filePath: string): void => { + const key = cacheProgressKey(mountId, filePath) + const prev = cacheProgress[key] + cacheProgress[key] = { total: prev?.total || 0, received: prev?.received || 0, done: true } +} + +export const clearCacheProgress = (mountId: string, filePath: string): void => { + delete cacheProgress[cacheProgressKey(mountId, filePath)] + delete inFlight[cacheProgressKey(mountId, filePath)] +} + +/** + * 完整缓存下载并落盘(单飞去重:同一文件并发请求只触发一次下载)。 + * 返回 Promise,resolve(true) 表示完整下载完成。 + */ +export const downloadToCache = (mount: WebDAVMount, filePath: string, onProgress?: (received: number, total: number) => void): Promise => { + const key = cacheProgressKey(mount.id, filePath) + if (isFileCached(mount, filePath)) return Promise.resolve(true) + if (inFlight[key]) return inFlight[key] + + const task = new Promise((resolve) => { + const tmpPath = getCacheFilePath(mount, filePath) + '.tmp' + const cacheFilePath = getCacheFilePath(mount, filePath) + let received = 0 + let total = 0 + try { + if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath) + } catch (e) { /* ignore */ } + const proxyReq = stream(mount, filePath) + const cleanup = (finish: boolean) => { + delete inFlight[key] + if (!finish) { + try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath) } catch (e) { /* ignore */ } + clearCacheProgress(mount.id, filePath) + } + } + proxyReq.on('error', () => cleanup(false)) + proxyReq.on('response', (resp: any) => { + const statusCode = resp.statusCode || 200 + if (statusCode >= 300 && statusCode < 400 && resp.headers['location']) { + resp.resume() + cleanup(false) + resolve(false) + return + } + if (statusCode >= 400) { + resp.resume() + cleanup(false) + resolve(false) + return + } + total = parseInt(resp.headers['content-length'] || '0', 10) + trackCacheProgress(mount.id, filePath, total, 0) + const ws = fs.createWriteStream(tmpPath, { flags: 'w' }) + resp.on('data', (chunk: any) => { + received += chunk.length + ws.write(chunk) + trackCacheProgress(mount.id, filePath, total, received) + if (onProgress) onProgress(received, total) + }) + resp.on('end', () => { + ws.end(() => { + if (total === 0 || received >= total) { + try { fs.renameSync(tmpPath, cacheFilePath) } catch (e) { + try { fs.unlinkSync(tmpPath) } catch (e2) { /* ignore */ } + } + markCacheDone(mount.id, filePath) + cleanup(true) + resolve(true) + } else { + cleanup(false) + resolve(false) + } + }) + }) + resp.on('error', () => { + ws.destroy() + cleanup(false) + resolve(false) + }) + }) + }) + inFlight[key] = task + return task +} + +/** + * 缓存汇总:文件数/占用大小;不指定 mountId 时为全部挂载 + */ +export const cacheStatus = (mountId?: string): { fileCount: number; size: number } => { + const base = path.join(global.lx.dataPath, 'webdav-cache') + let fileCount = 0 + let size = 0 + const scan = (dir: string) => { + if (!fs.existsSync(dir)) return + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name) + if (entry.isDirectory()) scan(p) + else if (!entry.name.endsWith('.tmp')) { + fileCount++ + try { size += fs.statSync(p).size } catch (e) { /* ignore */ } + } + } + } + if (mountId) scan(path.join(base, mountId)) + else scan(base) + return { fileCount, size } +} + +/** + * 清空缓存目录 + */ +export const clearCache = (mountId?: string): void => { + const base = path.join(global.lx.dataPath, 'webdav-cache') + if (mountId) { + const dir = path.join(base, mountId) + try { fs.rmSync(dir, { recursive: true, force: true }) } catch (e) { /* ignore */ } + } else { + try { fs.rmSync(base, { recursive: true, force: true }) } catch (e) { /* ignore */ } + } +} From 9d2d721613eb9224f1b1f15daed3d67a94f4b778 Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Wed, 5 Aug 2026 14:08:12 +0000 Subject: [PATCH 13/39] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20WebDAV=20?= =?UTF-8?q?=E6=8C=82=E8=BD=BD=E6=9E=84=E5=BB=BA/=E6=B5=8B=E8=AF=95/?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E7=9F=A5=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .monkeycode/MEMORY.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.monkeycode/MEMORY.md b/.monkeycode/MEMORY.md index 023f706b..5adf8313 100644 --- a/.monkeycode/MEMORY.md +++ b/.monkeycode/MEMORY.md @@ -55,3 +55,14 @@ Entries discovered by the Agent during task execution should follow this format: - OpenList `/d/` 直链会 302 到对象存储/CDN(如阿里云盘 OSS 签名 URL),代理必须服务端跟随重定向(最多 5 跳),否则播放器拿到无 Location 的 302 无法播放。OSS 签名 URL 可直接访问,无需转发 Authorization。 - 远程目录树扫描必须加防护:单目录 listFiles 加 20s 超时(needle 对超大目录可能永久挂起)、整体 60s 截止、目录数上限 800、子目录并发 6,否则真实 OpenList(含大量网盘挂载)递归扫描会把进程拖死。 - 本地音乐整合 OpenList:`/api/openlist/local-list?server=&refresh=` 递归扫描生成索引(TTL 120s);`/api/music/cache/list` 后端合并 folder='openlist' 条目;前端 local_music.js 过滤 tab 加 openlist 选项,内嵌目录树面板(`lm-ol-*` 元素 + LocalMusicManager.ol* 方法),收藏走 openlist 字段(url/serverId/path/sign)恢复播放。 + +[Project Knowledge Summary] +- Date: 2026-08-05 +- Context: Discovered by Agent while implementing WebDAV 音乐挂载功能(边播边缓存+目录歌单) +- Category: Build Methods / Troubleshooting & Debugging +- Instructions: + - 测试框架:node:test + `npx tsx --test `(项目无 vitest/jest);mock WebDAV 服务器须注意 PROPFIND 目录 key 去尾部斜杠归一化、响应过滤 `.`/`..`、GET 支持 Range。 + - 新增 `src/server/webdavMount.ts`(挂载源 CRUD 持久化 webdav-mounts.json、密码脱敏 hasPassword、目录扫描防护同 openlist、边播边缓存 .tmp->rename、本地 Range 206/416);`subsonic.ts handleStream` 对 webdav_/openlist_/local source 走内部流 302,由内部流路由统一承担缓存优先(避免重复实现)。 + - `stream` 返回同步 ClientRequest,server.ts 路由用 try/catch 包裹而非 `.then()`(误用 `.then` 会 TS2339)。 + - 服务启动时 config.js 的 webdav.* 备份 restore 会阻塞监听(当前环境 host.docker.internal 不可达,需等网络超时约 2-3 分钟才完成启动);冒烟测试前先 curl 首页确认 200。 + - 构建/推送镜像:`docker build -t lxserver:webdav . && docker tag ... ghcr.io/boy6656598/lxserver:latest && docker push`;容器内产物路径为 `/server/server/server/*.js`(根目录是 `/server` 非 `/app`)。 From 6ba2f10a6ab46a98205df5543c94a3c7aea69e66 Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Wed, 5 Aug 2026 15:15:09 +0000 Subject: [PATCH 14/39] =?UTF-8?q?fix:=20WebDAV=20restore=20=E5=88=86?= =?UTF-8?q?=E5=B1=82=E5=88=97=E4=B8=BE+=E8=AF=B7=E6=B1=82=E8=B6=85?= =?UTF-8?q?=E6=97=B6,=20=E9=98=B2=E6=AD=A2=20dav=20=E6=97=A0=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=AF=BC=E8=87=B4=E5=90=AF=E5=8A=A8=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/config.js | 2 +- src/index.ts | 2 ++ src/utils/webdavSync.ts | 65 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/public/js/config.js b/public/js/config.js index cb51ad5e..bde1214f 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -2,6 +2,6 @@ // 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts) // 服务端拦截 /js/config.js 请求, 读取此处版本号并合并服务端配置后返回 window.CONFIG = { - buildHash: 'fff0ad8', + buildHash: 'dc9cacb', version: 'v2.0.0', }; diff --git a/src/index.ts b/src/index.ts index af82f771..535f91d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -477,6 +477,8 @@ if (webdavSync.isConfigured()) { } // 启动自动同步 webdavSync.startAutoSync() + }).catch((err: any) => { + console.error('WebDAV restore failed (non-fatal):', err) }) } else { console.log('WebDAV not configured, skipping remote restore') diff --git a/src/utils/webdavSync.ts b/src/utils/webdavSync.ts index 9b0b2126..280677c5 100644 --- a/src/utils/webdavSync.ts +++ b/src/utils/webdavSync.ts @@ -55,6 +55,20 @@ class WebDAVSync extends EventEmitter { private client: any = null private initPromise: Promise | null = null private ensuredDirs: Set = new Set() + private static REQUEST_TIMEOUT = 30000 // 单次远程请求超时(毫秒),防止 dav 服务无响应导致进程挂起 + private static LIST_DEPTH_LIMIT = 4 // 分层列举最大深度,避免单次 Depth:infinity 全树拉取触发 OOM + + private withTimeout(run: (signal: AbortSignal) => Promise, label: string, ms: number = WebDAVSync.REQUEST_TIMEOUT): Promise { + const controller = new AbortController() + const abortPromise = new Promise((_, reject) => { + controller.signal.addEventListener('abort', () => reject(new Error(`WebDAV ${label} timed out after ${ms}ms`)), { once: true }) + }) + const timer = setTimeout(() => controller.abort(), ms) + return Promise.race([run(controller.signal), abortPromise]).finally(() => { + clearTimeout(timer) + controller.abort() + }) + } constructor(config: WebDAVConfig, dataPath: string) { super() @@ -188,6 +202,33 @@ class WebDAVSync extends EventEmitter { return remoteFilename.replace(/^\/+/, '') } + // 分层列举远程目录(不使用 Depth:infinity),避免 dav 服务一次返回整个目录树导致 OOM 或挂起 + private async listRemoteFiles(remoteDir: string, depth = 0): Promise { + if (depth > WebDAVSync.LIST_DEPTH_LIMIT) return [] + let items: any[] = [] + try { + items = await this.withTimeout( + (signal) => this.client.getDirectoryContents(remoteDir, { deep: false, signal }), + `list ${remoteDir}` + ) + } catch (err: any) { + // 目录不存在或单层列举失败时返回空,交由调用方按降级逻辑处理 + console.log(`[WebDAV] List ${remoteDir} failed: ${err.message}`) + return [] + } + const files: any[] = [] + for (const item of items) { + if (item.type === 'file') { + files.push(item) + } else if (item.type === 'directory') { + const subDir = item.filename.endsWith('/') ? item.filename : item.filename + '/' + const subFiles = await this.listRemoteFiles(subDir, depth + 1) + files.push(...subFiles) + } + } + return files + } + private async runConcurrent( items: T[], concurrency: number, @@ -319,13 +360,16 @@ class WebDAVSync extends EventEmitter { fs.mkdirSync(localDir, { recursive: true }) } - const content = await this.client.getFileContents(remotePath) as any + const content = await this.withTimeout( + (signal) => this.client.getFileContents(remotePath, { signal }) as any, + `download ${remotePath}` + ) // 对比内容哈希,如果一致则跳过写入,避免触发文件系统监控 if (fs.existsSync(localPath)) { const localContent = fs.readFileSync(localPath) as any const localHash = crypto.createHash('md5').update(localContent).digest('hex') - const remoteHash = crypto.createHash('md5').update(content).digest('hex') + const remoteHash = crypto.createHash('md5').update(content as any).digest('hex') if (localHash === remoteHash) { // console.log(`File ${relativePath} is up to date, skipping write.`) @@ -569,7 +613,10 @@ class WebDAVSync extends EventEmitter { if (!this.client) return try { - const items = await this.client.getDirectoryContents(`${this.backupPath}/`) + const items = await this.withTimeout( + (signal) => this.client.getDirectoryContents(`${this.backupPath}/`, { signal }), + `list ${this.backupPath}` + ) const backups = items .filter((item: any) => item.basename.startsWith('lx-sync-backup-')) .sort((a: any, b: any) => this.parseBackupTime(b) - this.parseBackupTime(a)) @@ -590,7 +637,10 @@ class WebDAVSync extends EventEmitter { try { this.emit('progress', { type: 'restore', status: 'start', message: '正在获取备份列表...' }) - const items = await this.client.getDirectoryContents(`${this.backupPath}/`) + const items = await this.withTimeout( + (signal) => this.client.getDirectoryContents(`${this.backupPath}/`, { signal }), + `list ${this.backupPath}` + ) const backups = items .filter((item: any) => item.basename.startsWith('lx-sync-backup-')) .sort((a: any, b: any) => this.parseBackupTime(b) - this.parseBackupTime(a)) @@ -605,7 +655,10 @@ class WebDAVSync extends EventEmitter { message: `正在下载备份: ${latestBackup.basename}` }) - const content = await this.client.getFileContents(latestBackup.filename) + const content = await this.withTimeout( + (signal) => this.client.getFileContents(latestBackup.filename, { signal }) as any, + `download backup ${latestBackup.basename}` + ) const zipPath = path.join(this.dataPath, 'temp-restore.zip') // 修复类型错误:使用 as any @@ -692,7 +745,7 @@ class WebDAVSync extends EventEmitter { // 1. 尝试恢复散文件 try { - const items = await this.client.getDirectoryContents(`${this.syncPath}/`, { deep: true }) + const items = await this.listRemoteFiles(`${this.syncPath}/`) const files = items.filter((item: any) => item.type === 'file') if (files.length > 0) { From 71970c8aeed4627ca8c628bdcf6e98a2905a49e2 Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Thu, 6 Aug 2026 01:43:36 +0000 Subject: [PATCH 15/39] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E6=96=87=E4=BB=B6=E5=90=8C=E6=AD=A5=E4=B8=8A=E4=BA=91?= =?UTF-8?q?=E8=87=AA=E6=88=91=E6=B1=A1=E6=9F=93=E3=80=81WebDAV=20=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=A0=91=E8=B7=AF=E5=BE=84=E7=BF=BB=E5=80=8D=E4=B8=8E?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E6=BA=90=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - webdavSync.scanFiles 排除 openlist-cache/webdav-cache 缓存目录,防止音频缓存同步到 dav 后被挂载源索引为远程音乐(自我污染) - openlist/webdav 索引扫描跳过 lx-sync/lx-sync-backups 同步目录 - webdavMount.joinRemotePath 对已含 rootPath 前缀的路径去重,修复 browse 路径翻倍导致目录树不显示文件夹 - webdavMount 索引 path 统一为相对 baseUrl 的完整路径(含 rootPath),修复主列表 stream 播放路径错误 - local_music playItem 改用 buildPlaylistSong 构造播放列表,保留 source/name/singer/url,修复 Invalid songInfo 与 Unknown 歌曲名 - webdav 播放 URL 追加 token 供 audio 鉴权 --- public/js/config.js | 2 +- public/music/js/local_music.js | 28 ++++++++++++++++++++++------ src/server/openlist.ts | 3 +++ src/server/webdavMount.ts | 22 +++++++++++++++++----- src/utils/webdavSync.ts | 4 ++++ 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/public/js/config.js b/public/js/config.js index bde1214f..9f5e059d 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -2,6 +2,6 @@ // 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts) // 服务端拦截 /js/config.js 请求, 读取此处版本号并合并服务端配置后返回 window.CONFIG = { - buildHash: 'dc9cacb', + buildHash: 'cec214b', version: 'v2.0.0', }; diff --git a/public/music/js/local_music.js b/public/music/js/local_music.js index 1f7a3de9..405582ec 100644 --- a/public/music/js/local_music.js +++ b/public/music/js/local_music.js @@ -2358,6 +2358,9 @@ window.LocalMusicManager = { const songInfo = { ...item.songInfo, + name: item.name || item.songInfo?.name, + singer: item.singer || item.songInfo?.singer, + quality: item.quality || item.songInfo?.quality || item.songInfo?.type || '128k', // Reconstruct full URL locally url: buildLocalUrl(item), pic: (isOpenList || isWebdav) ? '' : `/api/music/cache/cover?filename=${encodeURIComponent(item.filename)}&user=${encodeURIComponent(username)}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`, @@ -2382,12 +2385,25 @@ window.LocalMusicManager = { // If 'app.js' exposes playSong(song), we use it. // We might want to construct a playlist of local tracks. - const playlist = this.displayData.map(d => ({ - ...d.songInfo, - url: buildLocalUrl(d), - pic: (d.folder === 'openlist' || d.openlist || d.folder === 'webdav' || d.webdav) ? '' : `/api/music/cache/cover?filename=${encodeURIComponent(d.filename)}&user=${encodeURIComponent(username)}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`, - isLocal: true - })); + // OpenList/WebDAV 合并条目是平铺结构(无嵌套 songInfo),必须走 buildPlaylistSong + // 保留 source/name/singer/url 等字段,否则播放时会被当作无源歌曲导致 Invalid songInfo + const playlist = this.displayData.map(d => { + const built = this.buildPlaylistSong(d); + if (built) { + // 与 playItem 原有行为一致:OpenList/WebDAV 播放 URL 追加 token 供