From f598aaba6edde8cbc1d344dc4fdf954076b49352 Mon Sep 17 00:00:00 2001 From: pavel2 Date: Fri, 23 Oct 2026 09:24:46 +0800 Subject: [PATCH 01/34] =?UTF-8?q?fix:=20=E4=BE=A7=E6=A0=8F=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E6=9D=A1=E4=B8=8E=20userApi=20=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=B0=8F=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/style.css | 40 +++++++++++++++++++++++++++++------ src/server/sources/userApi.ts | 2 +- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/public/style.css b/public/style.css index d4a58a6b..415a3dbd 100644 --- a/public/style.css +++ b/public/style.css @@ -493,7 +493,8 @@ body { margin: 1rem; display: flex; flex-direction: column; - gap: 2rem; + gap: 1rem; + /* [修复] 原 2rem 间距在菜单项较多时白白占用约 32px 纵向空间,导致列表更容易溢出 */ position: sticky; top: 1rem; height: calc(100vh - 2rem); @@ -524,15 +525,28 @@ body { gap: 0.5rem; overflow-y: auto; min-height: 0; - /* Hide scrollbar for Chrome, Safari and Opera */ - -ms-overflow-style: none; - /* IE and Edge */ - scrollbar-width: none; - /* Firefox */ + padding-right: 4px; + /* [修复] 菜单项多达 10 条时常溢出,此前滚动条被隐藏导致用户以为「菜单变少」。 + 改为细滚动条:既保留视觉简洁,又让溢出可被发现、可滚动。 */ + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.25) transparent; } .sidebar-nav::-webkit-scrollbar { - display: none; + width: 6px; +} + +.sidebar-nav::-webkit-scrollbar-track { + background: transparent; +} + +.sidebar-nav::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.sidebar-nav::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.35); } .nav-item { @@ -565,6 +579,18 @@ body { box-shadow: var(--shadow-md); } +/* [修复] 视口较矮时(笔记本常见 700~800px)侧栏进一步收紧,避免底部菜单被挤出可视区 */ +@media (max-height: 900px) { + .sidebar { + padding: 1.25rem 1rem; + gap: 0.75rem; + } + + .nav-item { + padding: 0.625rem 1rem; + } +} + .sidebar-divider { height: 1px; background: var(--glass-border); diff --git a/src/server/sources/userApi.ts b/src/server/sources/userApi.ts index 30ce91ca..6ba45bf2 100644 --- a/src/server/sources/userApi.ts +++ b/src/server/sources/userApi.ts @@ -368,7 +368,7 @@ export async function loadUserApi(apiInfo: UserApiInfo): Promise { count: (...args: any[]) => { if (global.lx.config['debug.enabled']) (console as any).count?.(...args) }, countReset: (...args: any[]) => { if (global.lx.config['debug.enabled']) (console as any).countReset?.(...args) }, assert: (condition?: boolean, ...data: any[]) => { if (global.lx.config['debug.enabled']) console.assert(condition, ...data) }, - dir: (...args: any[]) => { if (global.lx.config['debug.enabled']) console.dir(...args) }, + dir: (...args: any[]) => { if (global.lx.config['debug.enabled']) (console as any).dir(...args) }, dirxml: (...args: any[]) => { if (global.lx.config['debug.enabled']) (console as any).dirxml?.(...args) }, error: (...args: any[]) => { console.error(...args) } } From c72b778abb9415072e621fc7564a1a30da9f9355 Mon Sep 17 00:00:00 2001 From: pavel2 Date: Wed, 28 Oct 2026 13:17:50 +0800 Subject: [PATCH 02/34] =?UTF-8?q?feat(openlist):=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E6=9C=AA=E5=91=BD=E4=B8=AD=E4=BC=98=E5=85=88=E4=BB=8E=20OpenLi?= =?UTF-8?q?st=20=E5=8F=96=E5=9B=9E/=E7=9B=B4=E9=93=BE=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=A4=96=E7=BD=91=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E4=B8=8E=E4=BA=A4=E4=BB=98=E6=96=B9=E5=BC=8F=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=20(v2.1.4.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 4 + package.json | 4 +- public/app.js | 449 ++++++++++++++++++++++++++++ public/index.html | 176 +++++++++++ public/js/config.js | 4 +- src/defaultConfig.ts | 16 + src/server/integrations/subsonic.ts | 40 ++- src/server/music/fileCache.ts | 56 +++- src/server/openlistBackup.ts | Bin 0 -> 80236 bytes src/server/openlistBackupDb.ts | Bin 0 -> 37456 bytes src/server/openlistClient.ts | Bin 0 -> 19192 bytes src/server/server.ts | 206 ++++++++++++- src/types/config.d.ts | 30 ++ 13 files changed, 978 insertions(+), 7 deletions(-) create mode 100644 src/server/openlistBackup.ts create mode 100644 src/server/openlistBackupDb.ts create mode 100644 src/server/openlistClient.ts diff --git a/Dockerfile b/Dockerfile index d269e856..21488fd9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,10 @@ WORKDIR /server RUN apk add --update --no-cache chromaprint \ || echo "chromaprint apk not found, will use bundled binary" +# OpenList 备份依赖 node:sqlite(Node >= 22.13),构建期强校验,避免运行时才报错 +RUN node -e "process.exit((process.versions.node.split('.')[0]|0)*1000+(process.versions.node.split('.')[1]|0) >= 2213 ? 0 : 1)" \ + || { echo 'node >= 22.13 required for OpenList backup (node:sqlite)'; exit 1; } + COPY --from=deps /deps/node_modules ./node_modules COPY --from=builder /source-code/server ./server COPY --from=builder /source-code/public ./public diff --git a/package.json b/package.json index d2df1cd2..ee0c9f64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lx-music-sync-server", - "version": "2.1.4", + "version": "2.1.4.1", "private": true, "scripts": { "prebuild": "npm run build:css && npm run build:files && node scripts/download-binary.js && node scripts/update-build-hash.js", @@ -24,7 +24,7 @@ "build:files": "node scripts/build-filemanager.mjs" }, "engines": { - "node": ">= 22", + "node": ">=22.13.0", "npm": ">= 8.5.2" }, "repository": { diff --git a/public/app.js b/public/app.js index aba1edf1..95d30e04 100644 --- a/public/app.js +++ b/public/app.js @@ -504,6 +504,7 @@ class App { logs: '系统日志', webdav: 'WebDAV同步', subsonic: 'Subsonic 配置', + openlist: 'OpenList 同步', files: '文件管理', snapshots: '快照管理', about: '关于' @@ -550,6 +551,9 @@ class App { this.loadConfigBackups(); } break; + case 'openlist': + this.loadOpenlistConfig(); + break; case 'about': this.loadAbout(); break; @@ -3377,6 +3381,12 @@ class App { if (form.elements['subsonic.playCacheFirst']) { form.elements['subsonic.playCacheFirst'].checked = config['subsonic.playCacheFirst'] === true; } + if (form.elements['subsonic.playCacheFirstUseOpenlist']) { + form.elements['subsonic.playCacheFirstUseOpenlist'].checked = config['subsonic.playCacheFirstUseOpenlist'] === true; + } + if (form.elements['subsonic.openlistDelivery']) { + form.elements['subsonic.openlistDelivery'].value = config['subsonic.openlistDelivery'] || 'direct'; + } // 不喜欢 / 评分联动 @@ -3458,6 +3468,8 @@ class App { 'subsonic.lyricTranslation': formData.get('subsonic.lyricTranslation') === 'on', 'subsonic.cacheOnPlay': formData.get('subsonic.cacheOnPlay') === 'on', 'subsonic.playCacheFirst': formData.get('subsonic.playCacheFirst') === 'on', + 'subsonic.playCacheFirstUseOpenlist': formData.get('subsonic.playCacheFirstUseOpenlist') === 'on', + 'subsonic.openlistDelivery': (formData.get('subsonic.openlistDelivery') || 'direct').trim(), 'subsonic.dislikeRating': Number(formData.get('subsonic.dislikeRating') || 1), 'subsonic.hideDisliked': formData.get('subsonic.hideDisliked') === 'on', @@ -3600,6 +3612,443 @@ class App { document.getElementById('modal').classList.add('hidden'); } + + // ===== OpenList 音频备份配置 ===== + async loadOpenlistConfig() { + try { + const config = await this.request('/api/config') + const form = document.getElementById('openlist-form') + if (!form) return + form.elements['openlist.enable'].checked = config['openlist.enable'] === true + form.elements['openlist.endpoint'].value = config['openlist.endpoint'] || '' + form.elements['openlist.token'].value = config['openlist.token'] || '' + form.elements['openlist.root'].value = config['openlist.root'] || '/tyCloud' + form.elements['openlist.keepLocalAfterUpload'].checked = config['openlist.keepLocalAfterUpload'] !== false + form.elements['openlist.externalUrl'].value = config['openlist.externalUrl'] || '' + form.elements['openlist.includeNonAudio'].checked = config['openlist.includeNonAudio'] === true + form.elements['openlist.verifyInterval'].value = config['openlist.verifyInterval'] || 5 + this.toggleOpenlistVisibility() + form.elements['openlist.enable'].onchange = () => this.toggleOpenlistVisibility() + + let users = [] + try { + const ulist = await this.request('/api/users') + users = (ulist || []).map(u => u.name).filter(Boolean) + } catch (e) { console.warn('获取用户列表失败', e) } + this.renderOpenlistUserConfig(users, config['openlist.userBackups']) + this.loadOpenlistBackups() + } catch (e) { + console.error(e) + if (window.showError) window.showError('加载 OpenList 配置失败: ' + (e && e.message || e)) + } + } + + async saveOpenlistConfig() { + const form = document.getElementById('openlist-form') + if (!form) return + const fd = new FormData(form) + const cfg = { + 'openlist.enable': fd.get('openlist.enable') === 'on', + 'openlist.endpoint': (fd.get('openlist.endpoint') || '').trim(), + 'openlist.token': fd.get('openlist.token'), + 'openlist.root': (fd.get('openlist.root') || '').trim() || '/tyCloud', + 'openlist.keepLocalAfterUpload': fd.get('openlist.keepLocalAfterUpload') === 'on', + 'openlist.includeNonAudio': fd.get('openlist.includeNonAudio') === 'on', + 'openlist.externalUrl': (fd.get('openlist.externalUrl') || '').trim(), + 'openlist.verifyInterval': parseInt(fd.get('openlist.verifyInterval')) || 5, + 'openlist.userBackups': this.collectOpenlistUserBackups(), + } + try { + await this.request('/api/config', { method: 'POST', body: JSON.stringify(cfg) }) + if (window.showSuccess) window.showSuccess('OpenList 配置已保存') + else alert('OpenList 配置已保存') + } catch (e) { + console.error(e) + if (window.showError) window.showError('保存失败: ' + (e && e.message || e)) + } + } + + toggleOpenlistVisibility() { + const form = document.getElementById('openlist-form') + const enabled = !!(form && form.elements['openlist.enable'] && form.elements['openlist.enable'].checked) + const opts = document.getElementById('openlist-options') + const hint = document.getElementById('openlist-disabled-hint') + if (opts) opts.style.display = enabled ? 'block' : 'none' + if (hint) hint.style.display = enabled ? 'none' : 'block' + } + + renderOpenlistUserConfig(users, userBackupsRaw) { + let backups = [] + try { backups = JSON.parse(userBackupsRaw || '[]') } catch {} + const selected = backups.map(b => b.user) + const labelMap = {} + users.forEach(u => { labelMap[u] = u }) + this.renderSortableTagGroup('tag-group-openlist-users', 'openlist.userBackupUsers', selected, labelMap, users) + const container = document.getElementById('tag-group-openlist-users') + if (container) container.onclick = () => setTimeout(() => this.renderOpenlistUserDirs(backups), 0) + this.renderOpenlistUserDirs(backups) + } + + renderOpenlistUserDirs(backups) { + const hidden = document.querySelector('input[name="openlist.userBackupUsers"]') + const selected = (hidden && hidden.value ? hidden.value.split(',').map(s => s.trim()).filter(Boolean) : []) + const dirsContainer = document.getElementById('openlist-user-dirs') + if (!dirsContainer) return + const map = {} + ;(backups || []).forEach(b => { map[b.user] = b }) + const prev = {} + selected.forEach(u => { + const d = document.getElementById('openlist-dl-sw-' + u) + const c = document.getElementById('openlist-ca-sw-' + u) + const m = document.getElementById('openlist-mode-' + u) + prev[u] = { download: d ? d.checked : null, cache: c ? c.checked : null, mode: m ? m.value : '' } + }) + const modeOptions = (sel) => ([ + ['upload', '上传(保留本地)'], + ['upload-delete', '上传后删除本地'], + ['download', '下载(远端→本地)'], + ['sync', '双向同步'], + ]).map(([v, t]) => '').join('') + const modeHelp = '上传:本地新增自动上传,保留本地;上传后删除:上传完成即删本地并清缓存索引;下载:仅把云端该用户目录拉到本地并补缓存;双向同步:本地/云端任一侧新增或删除都会双向同步' + const toggleHtml = (id, checked) => + '' + dirsContainer.innerHTML = selected.map(u => { + const tpl = map[u] || {} + const dlOn = (prev[u] && prev[u].download !== null) ? prev[u].download : (tpl.backupDownload !== false) + const caOn = (prev[u] && prev[u].cache !== null) ? prev[u].cache : (tpl.backupCache !== false) + const md = (prev[u] && prev[u].mode) ? prev[u].mode : (tpl.mode || 'upload') + return '
' + + '' + + '
' + + '
' + toggleHtml('openlist-dl-sw-' + u, dlOn) + '
' + + '
' + toggleHtml('openlist-ca-sw-' + u, caOn) + '
' + + '
' + + '
' + + '
' + }).join('') + } + + collectOpenlistUserBackups() { + const hidden = document.querySelector('input[name="openlist.userBackupUsers"]') + const selected = (hidden && hidden.value ? hidden.value.split(',').map(s => s.trim()).filter(Boolean) : []) + return JSON.stringify(selected.map(u => { + const d = document.getElementById('openlist-dl-sw-' + u) + const c = document.getElementById('openlist-ca-sw-' + u) + const m = document.getElementById('openlist-mode-' + u) + return { user: u, enabled: true, backupDownload: d ? !!d.checked : true, backupCache: c ? !!c.checked : true, mode: (m ? m.value : 'upload') } + })) + } + + async testOpenlistConnection() { + const form = document.getElementById('openlist-form') + if (!form) return + const fd = new FormData(form) + const payload = { + endpoint: (fd.get('openlist.endpoint') || '').trim(), + token: fd.get('openlist.token'), + root: (fd.get('openlist.root') || '').trim() || '/tyCloud', + } + if (!payload.endpoint) { + if (window.showError) window.showError('请先填写 OpenList 服务器地址') + return + } + try { + const r = await this.request('/api/openlist/test', { method: 'POST', body: JSON.stringify(payload) }) + if (r && r.success && r.ok) { + if (window.showSuccess) window.showSuccess('OpenList 连接成功') + else alert('连接成功') + } else { + if (window.showError) window.showError('连接失败: ' + ((r && r.error) || '未知错误')) + else alert('连接失败: ' + ((r && r.error) || '')) + } + } catch (e) { + console.error(e) + if (window.showError) window.showError('测试连接出错: ' + (e && e.message || e)) + } + } + + async runOpenlistSync() { + try { + if (window.showInfo) window.showInfo('正在同步,请稍候…') + const r = await this.request('/api/openlist/sync', { method: 'POST', body: JSON.stringify({}) }) + if (r && r.success) { + const s = r.summary || {} + const msg = '同步完成:上传 ' + (s.uploaded || 0) + ',下载 ' + (s.downloaded || 0) + ',删除 ' + (s.deleted || 0) + + ((s.errors && s.errors.length) ? (',错误 ' + s.errors.length + ' 项') : '') + if (window.showSuccess) window.showSuccess(msg) + else alert(msg) + if (s.errors && s.errors.length) console.warn('OpenList 同步错误', s.errors) + this.loadOpenlistBackups() + } else { + if (window.showError) window.showError('同步失败: ' + ((r && r.error) || '')) + } + } catch (e) { + console.error(e) + if (window.showError) window.showError('同步出错: ' + (e && e.message || e)) + } + } + + async loadOpenlistBackups(silent) { + const box = document.getElementById('openlist-backups-list') + if (!box) return + if (!silent) box.innerHTML = '
加载中…
' + try { + const r = await this.request('/api/openlist/backups') + let list = (r && r.list) || [] + // 状态指标卡:基于全量列表统计各状态数量(deleted 不计入 list,通常为 0) + const STATUS_LABEL = { pending: '待上传', uploading: '进行中', completed: '已完成', verified: '已验证', failed: '失败', deleted: '已删除' } + const STATUS_COLOR = { pending: '#64748b', uploading: '#3b82f6', completed: '#0ea5e9', verified: '#22c55e', failed: '#ef4444', deleted: '#94a3b8' } + const statusOrder = ['pending', 'uploading', 'completed', 'verified', 'failed', 'deleted'] + const statusCounts = {} + for (const s of statusOrder) statusCounts[s] = 0 + for (const f of list) { if (f && f.status && statusCounts[f.status] !== undefined) statusCounts[f.status]++ } + const cardsBox = document.getElementById('openlist-status-cards') + if (cardsBox) { + const activeStatus = (document.getElementById('olb-filter-status') || {}).value || '' + cardsBox.innerHTML = statusOrder.map((st) => { + const n = statusCounts[st] || 0 + const color = STATUS_COLOR[st] || '#64748b' + const label = STATUS_LABEL[st] || st + const activeStyle = activeStatus === st ? 'box-shadow:0 0 0 2px ' + color + ' inset;' : '' + return '
' + + '
' + n + '
' + + '
' + label + '
' + + '
' + }).join('') + } + // 填充「用户」筛选(仅在首次/选项不足时) + const userSel = document.getElementById('olb-filter-user') + if (userSel && userSel.options.length <= 1) { + const users = [...new Set(list.map(f => f.user).filter(Boolean))].sort() + users.forEach(u => { const o = document.createElement('option'); o.value = u; o.textContent = u; userSel.appendChild(o) }) + } + const fu = document.getElementById('olb-filter-user') + const ff = document.getElementById('olb-filter-folder') + const fs2 = document.getElementById('olb-filter-status') + const fn = document.getElementById('olb-filter-name') + const fp = document.getElementById('olb-filter-path') + const uv = fu ? fu.value : '' + const fv = ff ? ff.value : '' + const sv = fs2 ? fs2.value : '' + const qn = (fn && fn.value ? fn.value : '').trim().toLowerCase() + const qp = (fp && fp.value ? fp.value : '').trim().toLowerCase() + list = list.filter(f => { + if (uv && f.user !== uv) return false + if (fv && f.folder_type !== fv) return false + if (sv && f.status !== sv) return false + if (qn) { + const hay = ((f.name || '') + ' ' + (f.local_path || '') + ' ' + (f.remote_path || '')).toLowerCase() + if (!hay.includes(qn)) return false + } + if (qp) { + const hay = ((f.local_path || '') + ' ' + (f.remote_path || '')).toLowerCase() + if (!hay.includes(qp)) return false + } + return true + }) + const pageSize = this._olbPageSize || 50 + const total = list.length + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + this._olbTotalPages = totalPages + if (!this._olbPage || this._olbPage < 1) this._olbPage = 1 + if (this._olbPage > totalPages) this._olbPage = totalPages + const start = (this._olbPage - 1) * pageSize + if (!total) { + box.innerHTML = '
暂无符合条件的备份记录
' + return + } + const statusMap = { pending: '待上传', uploading: '进行中', completed: '已完成', verified: '已验证', failed: '失败', deleted: '已删除' } + const badgeColor = { pending: '#64748b', uploading: '#3b82f6', completed: '#0ea5e9', verified: '#22c55e', failed: '#ef4444', deleted: '#94a3b8' } + const fmtName = (f) => { + const nm = (f.name || '').toString().trim() + const base = nm || (f.local_path ? f.local_path.split(/[\\/]/).pop() : '') || '(未知)' + return base.replace(/ { + n = Number(n) || 0 + if (n < 1024) return n + ' B' + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB' + return (n / 1024 / 1024).toFixed(1) + ' MB' + } + const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + this._olbPageIds = list.slice(start, start + pageSize).map(f => f.id) + const rows = list.slice(start, start + pageSize).map(f => { + const d = new Date(f.updated_at || f.created_at || Date.now()) + const color = badgeColor[f.status] || '#64748b' + const label = statusMap[f.status] || f.status || '' + const typeLabel = f.folder_type === 'music' ? '下载' : (f.folder_type === 'cache' ? '缓存' : (f.folder_type === 'list' ? '快照' : (f.folder_type || ''))) + const canRetry = f.status !== 'verified' && f.status !== 'deleted' + const retryBtn = canRetry + ? '' + : '—' + const hasLocal = !!(this._olbLocalExists && this._olbLocalExists[f.id]) + const localMark = hasLocal ? '✓' : '✗' + const pathStr = f.local_path || f.remote_path || '' + const errStr = f.error || '' + const errCell = errStr + ? '' + esc(errStr.length > 60 ? errStr.slice(0, 60) + '…' : errStr) + '' + : '—' + const sel = this._olbSelected || (this._olbSelected = new Set()) + const checked = sel.has(f.id) ? 'checked' : '' + return '' + + '' + + '' + (f.user || '') + '' + + '' + typeLabel + '' + + '' + fmtName(f) + '' + + '' + label + '' + + '' + fmtSize(f.size) + '' + + '' + localMark + '' + + '' + errCell + '' + + '' + d.toLocaleString() + '' + + '' + retryBtn + '' + + '' + esc(pathStr) + '' + + '' + }).join('') + const pager = '
' + + '' + + '' + + '第 ' + this._olbPage + ' / ' + totalPages + ' 页(共 ' + total + ' 条)' + + '' + + '' + + '
' + box.innerHTML = '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + rows + '
用户类型文件状态大小本地异常原因更新时间操作路径
' + pager + this.updateOlbSelCount() + } catch (e) { + console.error(e) + box.innerHTML = '
加载失败: ' + (e && e.message || e) + '
' + } + } + + async retryOpenlistBackup(id) { + try { + const r = await this.request('/api/openlist/retry', { method: 'POST', body: JSON.stringify({ id }) }) + if (r && r.success) { + if (window.showInfo) window.showInfo('已提交重试') + this.loadOpenlistBackups(true) + } else if (window.showError) { + window.showError('重试失败: ' + ((r && (r.message || r.error)) || '未知错误')) + } + } catch (e) { + console.error(e) + if (window.showError) window.showError('重试失败: ' + (e && e.message || e)) + } + } + + toggleOpenlistSelect(el) { + const id = parseInt(el.dataset.id) + if (!this._olbSelected) this._olbSelected = new Set() + if (el.checked) this._olbSelected.add(id); else this._olbSelected.delete(id) + this.updateOlbSelCount() + } + + toggleOpenlistSelectAll(el) { + if (!this._olbSelected) this._olbSelected = new Set() + const ids = this._olbPageIds || [] + ids.forEach(id => { if (el.checked) this._olbSelected.add(id); else this._olbSelected.delete(id) }) + document.querySelectorAll('.olb-sel').forEach(cb => { + const id = parseInt(cb.dataset.id) + cb.checked = this._olbSelected.has(id) + }) + this.updateOlbSelCount() + } + + updateOlbSelCount() { + const n = this._olbSelected ? this._olbSelected.size : 0 + const span = document.getElementById('olb-selcount') + if (span) span.textContent = n ? ('已选 ' + n + ' 项') : '' + const btn = document.getElementById('olb-bulkdel') + if (btn) btn.disabled = n === 0 + } + + async deleteSelectedOpenlist() { + if (!this._olbSelected || !this._olbSelected.size) return + const ids = Array.from(this._olbSelected) + if (!confirm('确认删除选中的 ' + ids.length + ' 条备份记录?\n将软删除记录并级联删除绑定的附件记录与文件(缓存音频副本一并删除;音乐库文件仅删记录不删本地)。')) return + try { + const r = await this.request('/api/openlist/delete', { method: 'POST', body: JSON.stringify({ ids }) }) + if (r && r.success) { + this._olbSelected.clear() + if (window.showInfo) window.showInfo('已删除 ' + (r.deleted || 0) + ' 条记录') + this.loadOpenlistBackups() + } else if (window.showError) { + window.showError('删除失败: ' + ((r && r.error) || '未知错误')) + } + } catch (e) { + if (window.showError) window.showError('删除失败: ' + (e && e.message || e)) + } + } + + switchOpenlistTab(name) { + const map = { + config: ['tab-btn-openlist-config', 'openlist-pane-config'], + records: ['tab-btn-openlist-records', 'openlist-pane-records'], + } + for (const [btnId, paneId] of Object.values(map)) { + const b = document.getElementById(btnId); if (b) b.classList.remove('active') + const p = document.getElementById(paneId); if (p) p.classList.remove('active') + } + const [btnId, paneId] = map[name] || map.config + const b = document.getElementById(btnId); if (b) b.classList.add('active') + const p = document.getElementById(paneId); if (p) p.classList.add('active') + if (name === 'records') this.loadOpenlistBackups() + } + + scheduleOpenlistReload() { + if (this._olbReloadTimer) clearTimeout(this._olbReloadTimer) + this._olbReloadTimer = setTimeout(() => { this._olbPage = 1; this.loadOpenlistBackups() }, 300) + } + + clearOpenlistFilters() { + ['olb-filter-user', 'olb-filter-folder', 'olb-filter-status', 'olb-filter-name', 'olb-filter-path'].forEach((id) => { + const el = document.getElementById(id); if (el) el.value = '' + }) + this._olbPage = 1 + this.loadOpenlistBackups() + } + + applyOpenlistFilter() { + this._olbPage = 1 + this.loadOpenlistBackups() + } + + filterOpenlistByStatus(status) { + const el = document.getElementById('olb-filter-status') + if (!el) return + // 再次点击当前已选状态则取消筛选 + el.value = (el.value === status) ? '' : status + this._olbPage = 1 + this.loadOpenlistBackups() + } + + gotoOpenlistPage(n) { + const tp = this._olbTotalPages || 1 + n = Math.max(1, Math.min(tp, parseInt(n) || 1)) + this._olbPage = n + this.loadOpenlistBackups(true) + } + + setOpenlistPageSize(v) { + this._olbPageSize = parseInt(v) || 50 + this._olbPage = 1 + this.loadOpenlistBackups() + } + + async request(url, options = {}) { const defaultOptions = { headers: { diff --git a/public/index.html b/public/index.html index 38745dee..2122fe23 100644 --- a/public/index.html +++ b/public/index.html @@ -200,6 +200,13 @@

LX Sync

Subsonic 配置 + + + + + OpenList 同步 + + Subsonic 协议配置 +
+
+ 缓存优先时优先使用 OpenList +
+ +
+
+ + +
@@ -2328,6 +2351,159 @@

备份与恢复管理

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

OpenList 连接

+

账号与服务器

+
+
+
+
+
+ 启用 OpenList 音频备份 ? +
+ +
+ + +
+
+
+ + + + +
+
+
+
+
+
+
+ +
+
+

同步状态 ?

+
+
+
+
+
+ + + +
+
+ + + + + + + +
+
?
+
+
+
+
+
+
diff --git a/public/js/config.js b/public/js/config.js index 6682e89a..782edad1 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -2,6 +2,6 @@ // 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts) // 服务端拦截 /js/config.js 请求, 使用 package.json 版本号并合并服务端配置后返回 window.CONFIG = { - buildHash: '634e348', - version: 'v2.1.4', + buildHash: '4b35e04', + version: 'v2.1.4.1', }; diff --git a/src/defaultConfig.ts b/src/defaultConfig.ts index 9142dec4..3f710f7b 100644 --- a/src/defaultConfig.ts +++ b/src/defaultConfig.ts @@ -111,6 +111,8 @@ const config: LX.Config = { 'subsonic.lyricTranslation': true, // 是否在 Subsonic 歌词中包含翻译 'subsonic.cacheOnPlay': false, // 是否在 Subsonic 播放时触发服务器缓存保存(落盘) 'subsonic.playCacheFirst': false, // 是否在 Subsonic 播放时优先使用服务器已有的本地缓存/下载文件直接传输(默认关闭:避免服务器自担出向流量/代理字节) + 'subsonic.playCacheFirstUseOpenlist': false, // 在「缓存优先」开启时,是否优先使用 OpenList 备份并以直链交付客户端 + 'subsonic.openlistDelivery': 'direct', // OpenList 命中后交付方式:direct=给直链(302),proxy=服务端代理转发 'subsonic.quality.enabled': true, // 音质优选总开关: 按优先级主动选最优可用音质(非失败降级); 关闭则 stream 仅单次解析 'subsonic.quality.priority': 'flac,320k,128k', // 音质优先级(从高到低, 逗号分隔), 按此顺序主动优选 'subsonic.source.priority': 'kw,tx,wy,mg,kg', // 跨平台优选顺序(逗号分隔, 客户端所选源始终优先) @@ -123,6 +125,20 @@ const config: LX.Config = { 'artist.maxFetchPages': 20, // 歌手歌曲最大抓取页数 'cache.namingPattern': 'simple', // 缓存命名规则 'system.allowUnsafeVM': false, // 是否允许运行 VM 模式自定义源脚本 + + // ===== OpenList 备份/同步 ===== + 'openlist.enable': false, // 是否启用 OpenList 备份/同步 + 'openlist.endpoint': '', // OpenList 地址(如 http://127.0.0.1:5244) + 'openlist.username': '', // OpenList 管理员账号(用于 testConnection) + 'openlist.password': '', // OpenList 密码 + 'openlist.token': '', // OpenList 用户令牌(与账号密码二选一) + 'openlist.root': '/lx_music', // OpenList 中 lx-music 备份根目录 + 'openlist.keepLocalAfterUpload': true, // 上传 OpenList 后是否保留本地文件 + 'openlist.verifyInterval': 5, // 远端可用性校验周期(分钟) + 'openlist.syncInterval': 60, // 双向同步周期(分钟) + 'openlist.userBackups': '[]', // 各用户备份目录配置(JSON 数组) + 'openlist.includeNonAudio': false, // 是否同步非音频文件(图片/封面等) + 'openlist.externalUrl': '', // OpenList 外网地址(留空则同 openlist.endpoint) } export default config diff --git a/src/server/integrations/subsonic.ts b/src/server/integrations/subsonic.ts index 1801df77..819437c7 100644 --- a/src/server/integrations/subsonic.ts +++ b/src/server/integrations/subsonic.ts @@ -1,9 +1,11 @@ import http from 'http' +import { Readable } from 'stream' import crypto from 'crypto' import { URL } from 'url' import { getUserSpace, getUserDirname, getUserConfig } from '@/user' import { callUserApiGetMusicUrl } from '@/server/sources/userApi' -import { downloadAndCache, checkCache, serveCacheFile } from '@/server/music/fileCache' +import { downloadAndCache, checkCache, serveCacheFile, getFileName } from '@/server/music/fileCache' +import { resolveOpenlistCacheDirectUrl } from '@/server/openlistBackup' import { getSingerPic, getSingerDetail, getSingerMid, nameSimilarity, resolveSingerSources, getOrderedSingerSources, normalizeName as normalizeSingerName, @@ -6240,6 +6242,42 @@ class SubsonicHandler { if (!musicInfo) { musicInfo = { source, songmid, id, meta: { songId: songmid } } } + + // OpenList 命中后交付:本地缓存未命中但 OpenList 已有该文件时, + // 按「交付方式」配置决定:direct=302 直链(客户端直连,省服务器出向流量);proxy=服务端代理转发(兼容不支持跳转的客户端)。 + // 仅在「缓存优先 + 优先使用 OpenList」同时开启时生效。 + if (method === 'stream' || method === 'download') { + const openlistBase = getFileName(musicInfo, quality, false, username) + const directUrl = await resolveOpenlistCacheDirectUrl(username, 'cache', openlistBase) + if (directUrl) { + const delivery = (global.lx.config['subsonic.openlistDelivery'] as string) || 'direct' + if (delivery === 'proxy') { + try { + const fwd: Record = {} + if (req.headers.range) fwd['Range'] = String(req.headers.range) + const upstream = await fetch(directUrl, { headers: fwd, redirect: 'follow' }) + const outHeaders: Record = { 'Accept-Ranges': 'bytes' } + const ct = upstream.headers.get('content-type'); if (ct) outHeaders['Content-Type'] = ct + const cl = upstream.headers.get('content-length'); if (cl) outHeaders['Content-Length'] = cl + const cr = upstream.headers.get('content-range'); if (cr) outHeaders['Content-Range'] = cr + res.writeHead(upstream.status, outHeaders) + if (upstream.body) Readable.fromWeb(upstream.body as any).pipe(res) + else res.end(Buffer.from(await upstream.arrayBuffer())) + subsonicLog.debug(`[Subsonic] ${method} proxied OpenList for ${id} (status ${upstream.status})`) + return + } catch (e: any) { + subsonicLog.warn(`[Subsonic] ${method} proxy OpenList failed for ${id}:`, e?.message || e) + // 失败则继续走原回源逻辑 + } + } else { + subsonicLog.debug(`[Subsonic] ${method} hit OpenList direct link for ${id}: ${directUrl.slice(0, 80)}`) + res.writeHead(302, { Location: directUrl }) + res.end() + return + } + } + } + // [诊断] 打印回源结果与客户端的歌曲元数据参数,便于排查 kw 等源仍为 Unknown 的问题 if (global.lx.config['subsonic.enableDebug']) { subsonicLog.debug(`[Subsonic] stream resolve ${id}: name=${musicInfo.name || '(空)'} singer=${musicInfo.singer || '(空)'} album=${musicInfo.meta?.albumName || '(空)'}`) diff --git a/src/server/music/fileCache.ts b/src/server/music/fileCache.ts index ca06198c..ee76e9dd 100644 --- a/src/server/music/fileCache.ts +++ b/src/server/music/fileCache.ts @@ -2,6 +2,8 @@ import { resolveContainedPath } from '../auth/security' import { serveFile } from '../http/serveFile' import { getCacheBrowseLocation } from './cacheBrowseLocation' import { isRemasterStage, remasterStaging } from '../task/remasterStaging' +import { onFileCached as openlistBackupOnFileCached, tryRestoreFromOpenlist } from '@/server/openlistBackup' +import { softDeleteLocalFileByPath, getActiveBackupLocalPaths } from '@/server/openlistBackupDb' import fs from 'fs' @@ -12,6 +14,19 @@ import https from 'https' import crypto from 'crypto' import { PassThrough } from 'stream' const { MusicTagger, MetaPicture } = require('music-tag-native') + +/** + * OpenList 同步/下载回填缓存索引的对外封装。 + * 供 openlistBackup 在「下载/双向同步」把远端文件落到本地后登记为缓存条目, + * 以及在「删除本地文件」时注销缓存条目,使应用能正确识别本地文件是否存在。 + */ +export function registerOpenListCacheFile(username: string, item: any, folder: 'cache' | 'music'): void { + indexManager.update(username, item, folder) +} + +export function unregisterOpenListCacheFile(username: string, songId: string, folder: 'cache' | 'music', quality?: string): void { + indexManager.remove(username, songId, folder, quality) +} import { buildLyrics, parseLyrics } from '../../utils/lrcTool' import { formatPlayTime } from '../../utils/common' import { getUserSpace } from '../../user' @@ -199,6 +214,7 @@ export interface CacheItem { requestedSource?: string downloadSource?: string sourceName?: string + origin?: 'openlist' | 'platform' | 'local' quality: string filename: string folder: string // 'cache' or 'music' @@ -2325,6 +2341,21 @@ const downloadAndCacheImpl = async (songInfo: any, url: string, quality?: string } if (signal?.aborted) return + + // Q1:缓存未命中时先查 OpenList 是否已有该文件的 verified 备份,有则直接取回, + // 避免回源平台重复下载并重新上传(保留平台来源字段,仅用 origin 标记回写管道)。 + // 该路径服务「原生客户端 / Web」等走 lx-server 自有接口的请求,与 Subsonic 协议开关解耦: + // 仅由 openlist 自身开关(enable + 用户备份配置)控制,不受 subsonic.playCacheFirst* 影响。 + if (username) { + const targetFolder: 'cache' | 'music' = isOnlyDownload ? 'music' : 'cache' + const restored = await tryRestoreFromOpenlist(username, targetFolder, dir, baseName, quality || 'unknown') + if (restored) { + cacheProgress.set(songKey, { progress: 100, status: 'finished', total: restored.size, received: restored.size }) + expireProgress(songKey) + return Promise.resolve() + } + } + // 立即同步占坑标记下载中,关闭“拿到 200 响应前”的并发竞态窗口; // 后续到达的相同 songKey 请求会被上面的守卫跳过,不再重复下载。 cacheProgress.set(songKey, { progress: 0, status: 'downloading', total: 0, received: 0, speed: 0, updatedAt: Date.now() }) @@ -2560,6 +2591,20 @@ const downloadAndCacheImpl = async (songInfo: any, url: string, quality?: string nativeIds: extractNativeIds(songInfo) }, folderType) + // [OpenList 备份] 音频落盘后触发备份(按 per-user 配置判定是否上传) + if ((globalThis as any).lx?.config?.['openlist.enable']) { + try { + openlistBackupOnFileCached(normalizedUsername, folderType, finalPath, { + source: metadata.source, + songmid: id, + quality: actualQuality, + origin: 'platform', + }) + } catch (e) { + console.warn('[openlist-backup] 落盘钩子异常:', e) + } + } + let tagger: any let metadataWritable = false try { @@ -2967,7 +3012,9 @@ export const clearAllCache = (username?: string) => { for (const file of files) { try { const stats = fs.statSync(path.join(dir, file)) - fs.unlinkSync(path.join(dir, file)) + const fp = path.join(dir, file) + fs.unlinkSync(fp) + softDeleteLocalFileByPath(fp) deletedCount++; freedSize += stats.size } catch (e) { } } @@ -3133,11 +3180,18 @@ export const checkAndCleanupCache = async (username?: string) => { } } allFiles.sort((a, b) => a.mtime - b.mtime) + // 跳过「仍有活跃备份记录」的文件,避免本地缓存被清理后 OpenList 上传因源文件不存在而失败 + let activeBackupPaths: Set | null = null + try { activeBackupPaths = getActiveBackupLocalPaths() } catch { activeBackupPaths = null } let currentSize = totalSize const targetSize = limitBytes * 0.95 let deletedCount = 0 for (const file of allFiles) { if (currentSize <= targetSize) break + if (activeBackupPaths && activeBackupPaths.has(file.path)) { + if ((global as any).lx?.config?.['debug.enabled']) console.log(`[文件缓存] 跳过清理(仍有活跃备份记录): ${file.path}`) + continue + } try { fs.unlinkSync(file.path); currentSize -= file.size; deletedCount++ } catch (e) { } } console.log(`[文件缓存] 用户 ${normalizedUsername} 缓存空间清理完成,已删除 ${deletedCount} 个过期文件`) diff --git a/src/server/openlistBackup.ts b/src/server/openlistBackup.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2c9f5b929c8de095b2fab284fb8950c2110d9f8 GIT binary patch literal 80236 zcmeI550IVLRoI`QNmgVf!Zf&zTqY|fwKjIFmBW8Ib|OnQ7D#Wse`Hx*OKb1yX=Qf3 z5-VwCS+QjYrFGail0s|;Q^LTICXl9h+JPB{DQ%{N3=K}85GD*Hq_l!eN-!gP#E@cM z&+j{X_MH2D_j~(xCFc*#>`1%sefPWf+;h+WbMC$Ee;8<7CqHW&-`41p-+hh2#*s#= zG27VLxW93;py}5f7RZXnthD{`P4hwn*RAyuRM0>E`MkA@efX}Zy%I5X5`&% zjr$stjYINRU*qO8pPm1u_uVODYxWIVYyR%9K(tLpxxaw%SmTZIw)XtfL#?5{#z|J= z@7-gsk@4r{yE&N^IBZGFzQ(%IzTpE;y=iLmp2Nd$n(A%O@BiXV(|`tGsse)u~D z_S5Hf4ZUxF|MBM+-XPC6?|H7-zh_NH2>PVg_yYtVZvsPa`*VYPkK0(~SsZPg6ndCU z-nD+{<9A2CV-%eBwCUm5)(7rpMd`&hKwM&K$03|KDb#`JqAP zNbA2C;nRm68GZDccJJug(f>H~*h4>Y_Sx1e9{uNocMMhMbwIFsLZCA28a({gd3{Rg zwMxssGx0|wn+tpyBntJG=1tAs0@^iGFCKihb*c5Q-}OiHKCa{B*XFLecgxiJwsOe< zS+Ci~l#HrW7*{-fGAR&0P|$-?0Bfx8l-IteedFGL`NXpeeL@ZQN&nEyfktl|T=8wB zqXR;BPUn4%UwP#DC(a0UtZiOD`e%FAjt(?-H-?2H*0k>)`>FlC?VlN3H~QsAo*COU zxUR{1oF42y?(Yv2a^U^~&ci~FaQ_j3;h1nU(99Kc=xc10IV;s~S$e)P^X@_6lDhYJ zmV4)vyis1a$nkxRhf95K61j9(;6Q328IW4>jk;0h$v0^>Eo#G~r z4E61lStwoHDR3YM{0-x^@;mwdt@YM|#^O71Ue+po4kShqw+low1)sn>hCO?4tH21x zu|MDz#k{wRvNPY7{^uIZmX+Xre_L^sdv7^hsl|YlIo8O%?_=nC<=W)&3Y{U7j)|Oa z3AZ0@jLJ9NtXpmjefuuyNv)BSVfLMI3%@D7e}_u8#MgM(wkXwb?v*+QjyA;uSyzd`j}u7jfO?5*4HkX1=K4x zwfx&CYhGttZj|S5ue9-R&7Sjb%GQ*_q257X1B>KKk9~Ofx${p>oSAP-ZQQ$I`bev% z{mv&oadzG4g@tDqc8`B<@G7zHQ=6sn^p8&P)@K&hwDB+0!f@Frw!iSh-Hmbif45NX zp!_x5xJ~{V7A<(2%wFgFeFdi*G`mMOOk-cZb7=N7eJdl^mne8xNltiTm9HK%QtwoeH94QH@mTf4=_WYKsr}Yv04VEcOtKD zljqp{4yV})%LpjUhnXwdZdfdU5;f+I9pOTM37YG)LVCydX1VFCXXOC9gD zr~TOv{GxaO^nYC5yrzw{f8+6Wqt8DCcmDmv2ZaL-7L#`aLS^)_cJvnEKer8g+h1rY ze@@^1)8b<}e*&?sBywiEKpMTRx2|{w_P}x!WIUzmHNzZldT*uV4K;C14)ICQ- zp*7r+*eY01Ika28pOLqTAn|^F?jAunDf-G}*u1 zahq5gqk=^&s4=mjh{t@7{6>3h6r9fpgp*63A8359e72|eCC?5EjZQ5+J0Z{JmpL>p^BP;Jc z9V=+cX3yGJEfMu7wiR@(GNoTMK6IB-P+sOlj7}DBp`}kQ(oVn360V$(m7EcZIwY`T zDF_;xD; z?6^l{J(VRSalE;LBJu7Xk{(9ovql|}5L04-L!n1yj{P0SjXg6qK8SiD8wD3BB*x`Y z2G`B0+_v0z+H+27*Y#X(+qvgd&e!$ntvdG>bwXWV)+X&~prER8Su-$W{AE&|bzX@+ zsT;f7J`}bMB2kg4_|MPlhc?giNCxT8n8?u7qNuRzKZrSOe{Q>O*uM{kvXKO#B; zn`4t$35I-SzwNlKaaHa17zpuUdUB5B%uE{@mZR_5to;DsK-uCkg(|4bl*ErPGlHt8a za&W!v{^Rdy{?@y`^(U?#AtsQsjNKE9YqS!M)s^fQnOe58!G^|)jticdDc%J(Z%gnx zAXdda@-rYbe|_T(@>}g^x- zE@BLx(l#0euG0Ibniu!2nIby1^YIT)ezY||q}3GA_Pd|?*n(7vNT&K%j=tj^F+Q`B z^}Aj0MT~S<{y!m<$)_kWuk@;E|D9H>+`^|2kh%BE^M0Arrea=bPOyQ8 zn)*$fq@8B-@Wbbd>H+L)TIOl3>i45jW2^@&NFj{*0D7xpP4y(v0sO?1qUR4Ty`9J0 zMj4F=2mUuJt3Ke2yo(Kq4{zTQ6C*}2SA6GMIK{AHSK;jj8zzZ~@6<&N@)*I+EK6$o zaL5hgHb#A{WhEm-eAx&o7i|>mJFaNslx`aNm7-k-7ddKYVM9^9g9V}*8JMlMY|q%W zr{3H|hi_^ApXvW`@>1)?C%|zudaAC7yI$kVsT zH_?XItl$pmS);HT6((N@XWDp*H~UmK`mn6lto$HPrsQoPb~}T&_*a!87K?M-SS)TO z_;Ct}YpxcO!eZliOfSkwrz^9nSSQQMfe$c3bPan@qiHd!)eSBB3=N~2x$QL1{*ng* zRB>GBs1ByQUu>EuzIlAnAGT-doA60F+hJptpQUel#4C>G-=?jxqbwI<{o8lm^5I%e zjr;|zmE|DcA+7ov$g$s>!V~*F@l$q>pL+Yko-^|w7`yE$ELXm%*6aS=*!%XArR!-E z*|iF+a`tnei2K@8&oizmJfJP&vp7%mHQu|SuaQdA-u9u#x13zt)ch`xTvMsrCGUto zrFqA?oZx`K2%Kn9&D8ZZF1+LJvHm@;8~)2jzWfM&S5MpWlxy16aq!O8?(IJg%;oCs zO@$7xRxI|mpB~>lOzrXOPfSizcTG-lph0%@-?T0@&&+G4cU_Zu;6Hfe%v7WK;$vx@ zDv^o4#usP4{l4{Wf1CH#N?q_XG6Uv}J$2tw-AeJsI>Xqu-mmdAnV>=%wA78Gntx899J++cEk{@YFECmjwzWh+ZDY~xM(KYEq$g%huSQq=eY-#e_E~qZaIA(JeKcZe z)WRtq>v3(Pn3TeD=oT0DZp4LZ`rXC2bBnUiG@J8^RXi!5FOL@+NukVRq|>uJ??!AH z^?Fnm$$L~V{(!`Iblohb(7*T@IH-z8PE5*r5#PSQkQK-RdZHGHDrD%L6-lF;NJlgx zbrtIC)0(&%tqr2o<4{O=<{EZ6$(61K_BJ+WcQLqw1+U@p&q)SS?N?^Ev6zc$IQJZ5 z?n~`h{4}Ll%_on`d)-C#qHHPK#7@s9ZE|qJXBO3PD5}qO?b@O^Mu_6zWpRxZGN%I7Gfj z4Cbq}RLaM8K3|m_#&3qN6fVVwN(5paO4CP04r>)~e<7c39=V7`?9aw>X=C58VkyU) z@08k;k&B%kkw`1Sp3`qtI@Lo|rZAO6o#YYN8WFCUSh5+(_feg}j)Ah}#4ON3-~mZh zey)~PcagwZ=}D=|wOTc<+MkT)Sc!B>q^_Hud|uv9x}w&T$|i+SeO#UpiNv~QN5av@ z9>EdT53x#SLY1P=1nqug(L!_l4-^{Y2HC|{{^@IA)3Z-;^igWD%g_1Dy7SNCSGL;` z58_hG_BxVd>aFZ`q|I|p{G3jaDX>B83#-~|>s+S9yaM{ww11fnZjxt_+Q$V`uA!p- zpe0r#(Ge=Vt2CEt7~221j7!x7_}6Sz>@jVxIv)6}YCPJvk&2ENqbnU%>CiN_VZb@t zY02c1Wj@k*RW0k)?Tf=!15fZ`ex&#$zY3O%S>9HNeWiE@&qRNih2|Zw&8Y!)jFjb% zQm)D`>?{1=fntC3PfoD!6)L^?jP5pFH`?1iHUI3wmL{CBNY#V*k1stsGJek>-_kza zk15`Kvv)J!uO01g{Km-r6T9zOJ1P)uzGqN-Lhl}E^t9)t`duoFr;ZN(^6<|LU(>$u z4ufFr=<##EJpQoksr`4d@AGxT&yRfM@yY4g2Y+Z}*ZI^ZCrUvM@AG#}->um_qQ~jF zcjwK1YtSMC*azi`QP(EM%T!E7&7}T8-0!DrQPz~I2@J2aF;0MUt4~=u z=oYV$7F3IQ6q)zo{t1!l4=nq9hwxIq59n9xL#q8Ve$1=T1Kbtopmq{+6yIHU6|Ivf zaajZX@E*=AI4qVH+-RQUcoO#+7n!Ir8Y+#E)IDu>!d+@z+;`RFZOyAkes1KGdtZ3^ z-0ac)e>1YK`NGrvPf&MMg=ga4wR_i%o|#|LN28ZooC5LNL$eRIrq0dkyPO!XW|F>$ zZmw(o=G$ix6YukV()RDU_&XJD(Lvc^lcTR&?J~4CJJT`2zNW? z@+(yK#g=~~!qOBfu}Xj{JWxB46INPBFRxIU=F)XdagaW9EMRv`%Rk*`RqoN&JaU6c z@08Y}#$@F9*)(YShRd|{dy|Ukx2hND*)#;}6|2a!rUCv3m$7ydemo?&K3YVbHBzrI z=BtLa?M{*HS~<|EEmb1KtPU?mwDD}zf~%7Z6Y?C5qxy)x%Y1vZpbO^Kl1ildcG}bS z0uN}=Twkmlnvhu|X|!6kYHd2UA1-8QZv9gEZTVl7k8oml9vV`kr+xkC_NOj2*R+@S z(3HoqbIIzQo5d>Ka{9)-ms-7Yjt={OZ)kpQ@a{3Q8BNxanTc|Kpd$A^B~t3#>~rUz zZE-parwwfupV958@g+~xtxxqZ$^UF={`%OVyS`U^##`^{X+L*_ZPB`+$_JQU4z1yleO)kH2E}mB+4WADDh==t%4IAm5%Cdez=9J=SyIOOO7{a2$n- zi097#&O}dp%js=H|8wy0y;n~F?ON&aH_xtVUutP?&m(32w(fO?-tGLHd<2ymMy*A4 zFV*#}%1>y@_LcB=x)b;$uzZt%pWwVXZ;{Y_#q-UQt+@^0q)J(8`N8s<UOh!6j#@{$Zw1Q=Tk7w}UVQAkn>R|Gjhx$?6 z5FM;PXUk}a>Qaq%v)|lL_tUI(wO4vZwn2gB$@n^6+_$#Kg1okgo$7Wpk!W4bi~Dpv zv4j710UP_H2X1LySlBsz@!-1ACx$+=Q1&%#K1P=(3y%slSz_>=_J?%y^H8*H%E^3({6foWJ`@`PS%s`-PrpS8Z)}iZDL;8jp*akC z<-2OT>m>(PrLepW3q&hl|0rqHMzWn&^k+X&lOc0rrHJMek>Z2tehJnKJ)tJ>&MfK^sHK?yh6*# z%&1#(x+k^hzzrCs`Xy%t{^Rk*QccdzdLrej%c+<0GoKv8%j3`TyQV$0KhOUnr<{(F zN!XHT!3^1jim;O^{B>J>ECnC^t}iimNMWk~6toH#l(`Y3tLp7vGuOguf% z*g5^&!j4mG+KuM?ezjc5GJdQTEzpn4{TaMNCX>q2ORd)*ze7$qCOi0{!N$|o%8c?J z@21GBc!r{!Z9K7l5Q|rHti8qQtzT#z9=<2Jb)m0uaUUa|nJ2PC+ZX3QILUigZGJ({ zR|aBk==j*et^0SJa+p_R=dMK3x|e4%mv1`7J6B@mngxMOLE8IhoVla+L_B>bN;i8) zTUn({er|46Kpl0GD0*MWD)YchlWY)~r*M)z^=di#i|^Wd(RT3K1l<9kPWw{(1 z0iC81uyib-gjUIr7|d1sraUjrlbWvg*a2^9|5Ptfk>6Uv4VFh7+iexI6?CQwMP1fe zcRV;Y)G90QzU$OSY6$=pnc+@v7!GO6547hH_&&Mv%E1TTt{T;`F`lRIqRa63 z>U4^ZSMiN+A2a_pKYp*q*UOCn4zRYF<4SUg21~R9>pZ>Acm#qRWMwQm7u(q1s9s;* zqE^dbYay3X+nE>_IK;*y7m7@XnTM+NpHgPoR$I&eN&OIC1%a3IAbEe(kjRsk}62_10&HdqitFAUu>p%{)29oi}yt+ zoqqBbip*I~Y46=kJd3l=G~2;n)I?S_<=V~b+iz&T;?Z8Iv}}wIjP9B^@&U*pC3 ze|X_|3T^<$h4LFd!%b$a5n08q=SxB#_O_5sVY_7G}a~!!bM|B0KlCY~u zYY~anDo<$kUT3d6(9P@fzROxXH*N~oYDQOYth`DoOy~sDu}XE|$@WOG-^t$JS>T#= zVXwvwP42RreejvFjrZDKlC`5=sk3%7`#q}n%yG&=Ia|`#zy@~Ot5O?L1NO-nEJks+ z7C6^?i-x6N&Tcb56WW0S$#hd$1UE*%+^SP)*WFGY)4xp_)#`WS2ujh=<@%|v2ULZwE8ESs{zx>|-z|N&4&0JuRor&FoN7FqsbbNg zv$O3CE~CrkW}HeE?bW2C>!7q1mN@5T(XK2f^j#`-kp$OGJv@4YRLAzV?-;r;m6IQ6 zF-~;6ut0u?`rR_+sEoO$y+%$3!#71YZ62m~Dsbobp1IEQ`Wgi0j2F}cX{;qSg z_^|7m)E{Ww@vj_x{)vIcwfq0`*l)e-^f|IO$S->2PK>hL(pm;J$C+CjJJ3dq9 zsFk?brWV7*Zt86-1_m0Bo$Nn8P~2pcp2ySE{?>Q*AOH0hC*~Y^0=R)T_OG6Hs)IXM z5vgrIEK~`1qPNjQtnC98soB5eQPs;zqle3pnAV5-n^k#i&&%{%-!iS5%(u(O1mY_3 z9`tM{fNQKU%Imb6DM|=EI1~KCbZybenjeU3TV9`Bt!$rHd=}9(x?>3388ogBUR`vCvm6c>1O`>#y zraEqlowx>9K3iw8U)R$&3ob4ea?TqJ78SQS-;# zH^fql5fY=?y!?S;RuUmeu~L?>ikEJz)7Q#{MFSE^=@dJ`g2iq#E0wbjh?M;CBfl^v zr|myfJT31cds;8|t5R8jP*87XexU6a0>5R0covtJ{tnP#7e8m3aSMMD< zO?{HSskiKL+c0Mf<}rDTR41ixZ8?3_i5;ij(|qR;eWAnR8J|`^&B)hH;j4?!KLu90 zvskgQFVA9S9>_91h0rL;d|;2JXbwY9-QQhc>LvYq8@**?VX!B14YMNO{x7WI>E7fTvFykoJoM^Ew+EwvBK+PAT& zqE!j2GNHQ8)-r8pnzK9y<8hrs^~{OUVl-wzzvWxX%)iUuucA9oq8At?akRkmbKbDz z1yyHR)`8`D+5WlCdRXn+xT}6Z_N#I;%k}aPEvpZKv||TQ}treT<|Cs?(X82=52+)OLl_YQq*qI177Q< zhI=A5&cls+YwabE%7V9aMrF*Ft&7g_7_5CPaO#t{3WViqGs}`O@0IQG%EveI`>^sJ z@9Oh-aaPI)nart^ zgb{y60r@%wcy2jZA~yob>em(i%I{$D*d~-}^Gm&)6esSTl(91}J>Yj$*QriJtONr_ zd5ofwt@%RoSRF9Ya?i*hUsjK=a=hC1`=r=P$B%npR_eE{9?~mco0rB5%2Kh@8IAM% ze}9B%{Oh0kXC2OH)E?M1c&ybozH@r_$oj#5f8xa_|KeT0KXRnCb>zAxee|};3(({G z!Jm}d&dh%>Pqe3fzWLetZnJ~Htwlk6znxcAeNkvvq$+o^^|^L4a8KKr}p?|ka} zhsl)xiJbF(W}Y#Qv_5@a_ks*G-aYo3iFKpgqy6E@KamQ#M)SJnHL}BmygNFG$aJgt zcVO|n6}i{`sfp?5&ad6u8hZUxI$E?fcZpuOTXw~56WE*bKe}yragXb`e7aNi2@Fb` z?NUvveDmf)kHtQsJQ-So08ZU1+6?>XqkmqM>nVdVb8B-#C|0GY-Vs_aSKcB$tW4Tl zX3l*Gm+sYi;M9wWauaW8LPkeU)^Q)4heW`OdZXM%WZa7NRxd5ttsYxChOldWFD895 zej4qfZ+`Ori9IzrrL*8pO6*VF7iIfsT$3?Qv@GL%ZMUWM@AaZT1S1ath(yE^7AS-fwZ2c z?Ue91AO5bF4Uq$Anfg9h{H9y{scRXlvcuM-YL$$`^u(tp=ZD@l(Z7dEmoxMKaMusd z*?yA!Pt%H;ncg<%F<)4C@4p%u)EXM9Q`v1oU%rM_J?3_5jvA_a;B1DL<3k!o3(NQV zUx`W)uYcbul1oqFirLHj$Yj6q*NFrT%X{g~tu}+@s!+PK)~vVXZ~IWp)Z1?+-8RTP zHGYV60}A%bw!|k$clRLQVuZ>ep&gikw(V3(&6=p>2S0jdhvLBN*Svid-_AW|J1d6m z;YCp_QZAYqy%@RhRq_aX!V=0~xeZagdvTPNd8ck$$ ze6rL+=qyij_;sGnS5Y#8?tV#U#(JgF+E<+2j^s29a-;<9Rd?s>9iv{4wIeaIrQiGd z>bciitw?nA%HIXcI{UmFx?=wM2|Lq1Vao{y<+tW>*Fkzd~1X?G?^48Xr$dA&T} zL>hyR&=AlVFcVQ^Z$}Q3<@-UQ8Fhc-WF|x~Cz(i*|3uzI(Smy%O zHql1p*0kJd$+DJbK0E(Q@4IuXr@hd;)co;hK0J)|x3aU9Z_M7rDIV<}vo*PIQ!B2y zUuWGYwac**`s2;N8+*mb^-@j4Zeq^sj+L2RkC@dWF2U0JE z^PBbj;x%n{M9aC!!bv9tr>wr7_d}0s<=&7FPckMJutSuqovzx!*i1&pUTZ?s%4pVT z6>7-aojIDNbvL_7Y?M4j=f|VQ7yf4q?4MW(SiJ$8s4Fe}RNZ_VD3z*3Y^jrr@!2>& z{ZLPCXVPmg+z_7bnDbb&YNKNXPtOVe`4gWl-(ia(k50*7%9Zuja+GCiFYCKkvIg7M zL~{bIsV_ZNjcaoU!cPyKnTO_j+J80o$%pog{pxgzp_^sKRqU*3KX!6r`iY?p)6lJW za8sXqgud9BU?ZExv6be`TE{#ywzx;N|8D#PvrsKoY@UbF`mI6i#M) z7|(#qL5Cff@*1*2S1m?29CN%;x0bxe9!f@z)HW)Vr@KTw9-o7T_hzK6S;Yz&7KnPd z<>%3AuCCU~?dP~|j8(QS6rPvgc6JQ76`jLZCo4MQ|8lJ799|6;qlzjVUq3c<>ZP-) zkteX|+#cgoN(0y?y6Vxcj<_{Dqik23)J-kYyreRGGJX}`X%9KNOj|_D0c*nbO?+aq z&nls+eYkB=?ZuyU?kBZB(tgrso%=DVSBBC(Ci>pEXtf}t&090vMmyf~8AvR*=6B2c z;2%5Gvc9TEG=E@FR5gm}TE;~_)JOSJdi1fk3SBar&Zm|+Rn=>~90kQJs%o$}ud=T3 zs2I}tA<^C=1#I|~BL#$<@`hiAPBuN^eAKB<v47^boP3XK7N`(%uiK41F!8lDG?D?-)MzdKttV~ zfo_s7_BSjTnUx=%*g#S?zwiU9-TDMn+CQEiL5ia8x$<9247c;%%_B^#69w&Fgf>Sp^)+^N2b zt~yKWTdUUM3Z|=Pu#(S1*5o2%e6PN)LID3&;i6Se>(SaqiO(G)KWD34Rh$sDK<-fD-_68WCpj|dFH4y6p^=M zkM_4w4DxuVtbpbtJz8Yzk>@Jwh2L0JR9I}6o-FF+J-U5p@J1bO-{n?f`H@x6e&~_W zNA13AGR5oLA8(F~UntHA{L*6|dC$dtxic7E+q`b-BahDy{lpk2zI^9+|M5$5KH=$e zyN2lFIXS|IW+L`vgClKQo!1w{r~J8e8s$hHP}>h5JOW zpi}Ujz%blrJmEUm&Zjjh=dB#>?Rh6IsCyS#p?ro^&=cN=g*NDGRK9WAu($#d<2)QH z!H^Gz8K0NliP4$U3`_iWuItQKb+Ej#s2{x?YOn8E6 zrm?Kc@1*0TpR_Ob^iuu&-1#TvZo)gC+A!VQey5ze;+1V%rK)mN>VU_KD&|r7e}_~v zQ@gxw^mBt(O+rzA|BTdCAMZc@0~0?wL7#FjP;z5zUQ(zeEK7@MU*s&rI4V)Eyk%xG z!lew-U-_Z_wi&%&!fN~KFu;ha{WX_q=WRub`|4N+&fjGU02aeR&St2_56v6TwYCh7 zMj7knkB!(_$E9_BWk?2v^ZhI)wT1C)c;}MD=dcw9Zmt>uanZ^t-RP+Y^sf?@1bH*qvA0cXehyz1zxQu-}&( zi?Dw9opQN;vwB&U_NF+yZ{(EZI=;WPe({!6c5B#P3{HL0{E>OreT}OpPCt36_2QE$ z4zcN`1l|KkSCtedA#4?H5G=cl zrYAcK{(!7BmYvBZwSA~NF4F??_-lp7luz={{@w%a$oQ+*yl~7OHmkaCV z(}F^vA1eRwlHM&A*M>vyekMM>{8h(CxJzi5-0(q(idp?&m47;G z{3tOksx`apuqwKgQKQ=1L+?8du7#QyO0~$Syy=gtkoOA+xC3;@#f~ z<8q+f3t8T(jcO~k+LL=Mrv8 zb}dE|@P?o|?nmTwg9n8(xsB4s*%WI3P8KI mnS&bCQ@o{E3viXA1$sNA4m+kq4D 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 b$!x=B^C5e5jd2z4bBIZSt2AG46o>sETsbGW literal 0 HcmV?d00001 diff --git a/src/server/openlistClient.ts b/src/server/openlistClient.ts new file mode 100644 index 0000000000000000000000000000000000000000..57c05dd6216d9e2ca045983dd28711c75cea094c GIT binary patch literal 19192 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 { + const c: any = global.lx.config + const enable = newConfig['openlist.enable'] !== undefined ? !!newConfig['openlist.enable'] : !!c['openlist.enable'] + if (!enable) return + const endpoint = newConfig['openlist.endpoint'] !== undefined ? newConfig['openlist.endpoint'] : (c['openlist.endpoint'] || '') + const username = newConfig['openlist.username'] !== undefined ? newConfig['openlist.username'] : (c['openlist.username'] || '') + const password = newConfig['openlist.password'] !== undefined ? newConfig['openlist.password'] : (c['openlist.password'] || '') + const token = newConfig['openlist.token'] !== undefined ? newConfig['openlist.token'] : (c['openlist.token'] || '') + const root = newConfig['openlist.root'] !== undefined ? newConfig['openlist.root'] : (c['openlist.root'] || '/lx_music') + if (!endpoint) throw new Error('OpenList 地址(endpoint)不能为空') + const cl = new OpenListClient({ endpoint, username, password, token, root }) + try { + await cl.testConnection() + } catch (e: any) { + throw new Error('OpenList 账号校验失败:' + (e?.message || e)) + } + const rawUB = newConfig['openlist.userBackups'] !== undefined ? newConfig['openlist.userBackups'] : c['openlist.userBackups'] + let ub: any[] = [] + if (rawUB) { + try { ub = typeof rawUB === 'string' ? JSON.parse(rawUB) : rawUB } catch { ub = [] } + } + const users: any[] = c.users || [] + for (const e of ub) { + if (!e || !e.enabled) continue + if (!users.find((u: any) => u.name === e.user)) throw new Error(`OpenList 同步配置中的用户「${e.user}」不存在`) + for (const d of [e.downloadDir, e.cacheDir]) { + if (!d) continue + if (!fs.existsSync(d) || !fs.statSync(d).isDirectory()) throw new Error(`OpenList 同步目录不存在或无权限:${d}`) + fs.accessSync(d, fs.constants.R_OK) + } + } +} import * as customSourceHandlers from './sources/customSourceHandlers' import * as fileCache from './music/fileCache' import { withCacheBrowseLocation } from './music/cacheBrowseLocation' @@ -7251,7 +7286,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } if (req.method === 'POST') { - void readBody(req).then(body => { + void readBody(req).then(async (body) => { try { const newConfig = JSON.parse(body) if (newConfig.serverName !== undefined) global.lx.config.serverName = newConfig.serverName @@ -7398,6 +7433,27 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } } + // [OpenList 配置] 先实测校验可用性(强校验),再应用 + try { + await validateOpenListConfig(newConfig) + } catch (ve: any) { + res.writeHead(422, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: 'OpenList 配置校验失败:' + (ve?.message || ve) })) + return + } + if (newConfig['openlist.enable'] !== undefined) global.lx.config['openlist.enable'] = newConfig['openlist.enable'] + if (newConfig['openlist.endpoint'] !== undefined) global.lx.config['openlist.endpoint'] = newConfig['openlist.endpoint'] + if (newConfig['openlist.username'] !== undefined) global.lx.config['openlist.username'] = newConfig['openlist.username'] + if (newConfig['openlist.password'] !== undefined) global.lx.config['openlist.password'] = newConfig['openlist.password'] + if (newConfig['openlist.token'] !== undefined) global.lx.config['openlist.token'] = newConfig['openlist.token'] + if (newConfig['openlist.root'] !== undefined) global.lx.config['openlist.root'] = newConfig['openlist.root'] + if (newConfig['openlist.keepLocalAfterUpload'] !== undefined) global.lx.config['openlist.keepLocalAfterUpload'] = newConfig['openlist.keepLocalAfterUpload'] + if (newConfig['openlist.verifyInterval'] !== undefined) global.lx.config['openlist.verifyInterval'] = parseInt(newConfig['openlist.verifyInterval']) || 5 + if (newConfig['openlist.syncInterval'] !== undefined) global.lx.config['openlist.syncInterval'] = parseInt(newConfig['openlist.syncInterval']) || 60 + if (newConfig['openlist.userBackups'] !== undefined) global.lx.config['openlist.userBackups'] = newConfig['openlist.userBackups'] + if (newConfig['openlist.includeNonAudio'] !== undefined) global.lx.config['openlist.includeNonAudio'] = newConfig['openlist.includeNonAudio'] + if (newConfig['openlist.externalUrl'] !== undefined) global.lx.config['openlist.externalUrl'] = String(newConfig['openlist.externalUrl']).replace(/[\\/]+$/, '') + if (newConfig['snapshot.backupPath'] !== undefined) { try { global.lx.config['snapshot.backupPath'] = validateDirectoryPath(String(newConfig['snapshot.backupPath']), '歌单快照备份路径') @@ -7459,6 +7515,8 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi if (newConfig['subsonic.lyricTranslation'] !== undefined) global.lx.config['subsonic.lyricTranslation'] = newConfig['subsonic.lyricTranslation'] if (newConfig['subsonic.cacheOnPlay'] !== undefined) global.lx.config['subsonic.cacheOnPlay'] = newConfig['subsonic.cacheOnPlay'] if (newConfig['subsonic.playCacheFirst'] !== undefined) global.lx.config['subsonic.playCacheFirst'] = newConfig['subsonic.playCacheFirst'] + if (newConfig['subsonic.playCacheFirstUseOpenlist'] !== undefined) global.lx.config['subsonic.playCacheFirstUseOpenlist'] = newConfig['subsonic.playCacheFirstUseOpenlist'] + if (newConfig['subsonic.openlistDelivery'] !== undefined) global.lx.config['subsonic.openlistDelivery'] = newConfig['subsonic.openlistDelivery'] if (newConfig['subsonic.quality.enabled'] !== undefined) global.lx.config['subsonic.quality.enabled'] = !!newConfig['subsonic.quality.enabled'] if (newConfig['subsonic.quality.priority'] !== undefined) global.lx.config['subsonic.quality.priority'] = String(newConfig['subsonic.quality.priority']) if (newConfig['subsonic.source.priority'] !== undefined) global.lx.config['subsonic.source.priority'] = String(newConfig['subsonic.source.priority']) @@ -7567,6 +7625,8 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi 'subsonic.lyricTranslation': global.lx.config['subsonic.lyricTranslation'], 'subsonic.cacheOnPlay': global.lx.config['subsonic.cacheOnPlay'], 'subsonic.playCacheFirst': global.lx.config['subsonic.playCacheFirst'], + 'subsonic.playCacheFirstUseOpenlist': global.lx.config['subsonic.playCacheFirstUseOpenlist'] ?? false, + 'subsonic.openlistDelivery': global.lx.config['subsonic.openlistDelivery'] || 'direct', 'subsonic.quality.enabled': global.lx.config['subsonic.quality.enabled'], 'subsonic.quality.priority': global.lx.config['subsonic.quality.priority'], 'subsonic.source.priority': global.lx.config['subsonic.source.priority'], @@ -7576,6 +7636,18 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi 'configBackup.retentionDays': global.lx.config['configBackup.retentionDays'], 'configBackup.dir': global.lx.config['configBackup.dir'], 'snapshot.backupPath': global.lx.config['snapshot.backupPath'] || '', + 'openlist.enable': global.lx.config['openlist.enable'] ?? false, + 'openlist.endpoint': global.lx.config['openlist.endpoint'] || '', + 'openlist.username': global.lx.config['openlist.username'] || '', + 'openlist.password': global.lx.config['openlist.password'] || '', + 'openlist.token': global.lx.config['openlist.token'] || '', + 'openlist.root': global.lx.config['openlist.root'] || '/lx_music', + 'openlist.keepLocalAfterUpload': global.lx.config['openlist.keepLocalAfterUpload'] !== false, + 'openlist.externalUrl': global.lx.config['openlist.externalUrl'] || global.lx.config['openlist.endpoint'] || '', + 'openlist.verifyInterval': global.lx.config['openlist.verifyInterval'] || 5, + 'openlist.syncInterval': global.lx.config['openlist.syncInterval'] || 60, + 'openlist.userBackups': global.lx.config['openlist.userBackups'] || '[]', + 'openlist.includeNonAudio': global.lx.config['openlist.includeNonAudio'] === true, 'singer.sourcePriority': global.lx.config['singer.sourcePriority'], 'artist.maxFetchPages': global.lx.config['artist.maxFetchPages'], 'cache.namingPattern': global.lx.config['cache.namingPattern'], @@ -7716,6 +7788,136 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } // [配置备份管理 API] 下载指定备份文件 + // [OpenList 同步 API] 连接测试(不保存配置,仅校验) + if (pathname === '/api/openlist/test' && req.method === 'POST') { + const auth = req.headers['x-frontend-auth'] + if (auth !== global.lx.config['admin.password']) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: 'Unauthorized' })) + return + } + void readBody(req).then(async (body) => { + try { + const cfg = JSON.parse(body || '{}') + const endpoint = cfg.endpoint || global.lx.config['openlist.endpoint'] || '' + const username = cfg.username ?? (global.lx.config['openlist.username'] || '') + const password = cfg.password ?? (global.lx.config['openlist.password'] || '') + const token = cfg.token ?? (global.lx.config['openlist.token'] || '') + const root = cfg.root || global.lx.config['openlist.root'] || '/lx_music' + if (!endpoint) { + res.writeHead(422, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: 'OpenList 地址不能为空' })) + return + } + const cl = new OpenListClient({ endpoint, username, password, token, root }) + await cl.testConnection() + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, ok: true })) + } catch (e: any) { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, ok: false, error: e?.message || String(e) })) + } + }) + return + } + + // [OpenList 同步 API] 手动触发同步(upload/download/sync 全量) + if (pathname === '/api/openlist/sync' && req.method === 'POST') { + const auth = req.headers['x-frontend-auth'] + if (auth !== global.lx.config['admin.password']) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: 'Unauthorized' })) + return + } + void readBody(req).then(async (body) => { + try { + const cfg = JSON.parse(body || '{}') + const summary = await runSyncNow({ user: cfg.user, folder: cfg.folder }) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, summary })) + } catch (e: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: e?.message || String(e) })) + } + }) + return + } + + // [OpenList 同步 API] 备份/同步状态列表 + if (pathname === '/api/openlist/backups' && req.method === 'GET') { + const auth = req.headers['x-frontend-auth'] + if (auth !== global.lx.config['admin.password']) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: 'Unauthorized' })) + return + } + try { + const list = listBackups() + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, list })) + } catch (e: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: e?.message || String(e) })) + } + return + } + + // [OpenList 同步 API] 单文件手动重试 + if (pathname === '/api/openlist/retry' && req.method === 'POST') { + const auth = req.headers['x-frontend-auth'] + if (auth !== global.lx.config['admin.password']) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: 'Unauthorized' })) + return + } + void readBody(req).then(async (body) => { + try { + const cfg = JSON.parse(body || '{}') + const id = Number(cfg.id) + if (!id) { + res.writeHead(422, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: '缺少 id' })) + return + } + const rb = await retryBackup(id) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: rb.ok, message: rb.message || '' })) + } catch (e: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: e?.message || String(e) })) + } + }) + return + } + + // [OpenList 同步 API] 批量软删除备份记录(级联附件,deleted_at 软删除) + if (pathname === '/api/openlist/delete' && req.method === 'POST') { + const auth = req.headers['x-frontend-auth'] + if (auth !== global.lx.config['admin.password']) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: 'Unauthorized' })) + return + } + void readBody(req).then(async (body) => { + try { + const cfg = JSON.parse(body || '{}') + const ids = Array.isArray(cfg.ids) ? cfg.ids.map((x: any) => Number(x)).filter((x: number) => x > 0) : [] + if (!ids.length) { + res.writeHead(422, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: '缺少有效的 id 列表' })) + return + } + const r = await deleteOpenlistBackups(ids) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, deleted: r.deleted, remoteRemoved: r.remoteRemoved, remoteFailed: r.remoteFailed })) + } catch (e: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: e?.message || String(e) })) + } + }) + return + } + if (pathname === '/api/config/backups/download' && req.method === 'GET') { const auth = req.headers['x-frontend-auth'] if (auth !== global.lx.config['admin.password']) { @@ -8813,6 +9015,8 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi host = `http://${ip.includes(':') ? `[${ip}]` : ip}:${port}` httpServer.listen(port, ip) + // 启动 OpenList 校验调度器(按 openlist.verifyInterval 周期校验远端可用性) + startVerifyScheduler() }) // [Subsonic 独立端口] 在独立监听端口上只暴露 Subsonic API。 diff --git a/src/types/config.d.ts b/src/types/config.d.ts index e7c3e324..153fb36b 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -455,6 +455,10 @@ declare namespace LX { * 开启后,若服务器该用户目录下已存在此歌曲的缓存或下载文件,直接传输本地流,避免向源站请求在线直链;默认关闭以免服务器自担出向流量 */ 'subsonic.playCacheFirst'?: boolean + /** 在「缓存优先」开启的前提下,是否优先使用 OpenList 备份:缓存未命中时若 OpenList 已有该文件,则把网盘/外网直链直接给客户端(省服务器出向流量)。默认 false */ + 'subsonic.playCacheFirstUseOpenlist'?: boolean + /** OpenList 命中后的交付方式:'direct'=给直链(302 跳转,客户端直连网盘/OpenList,省服务器流量);'proxy'=服务端代理转发(兼容不支持跳转的客户端)。默认 'direct' */ + 'subsonic.openlistDelivery'?: 'direct' | 'proxy' /** * Subsonic 服务端转码总开关 (默认 false)。开启后,当音源无客户端请求音质(及更低音质)时, @@ -522,5 +526,31 @@ declare namespace LX { * 是否允许运行 VM 模式自定义源脚本 */ 'system.allowUnsafeVM'?: boolean + + // ===== OpenList 备份/同步 ===== + /** 是否启用 OpenList 备份/同步 */ + 'openlist.enable'?: boolean + /** OpenList 地址(如 http://127.0.0.1:5244) */ + 'openlist.endpoint'?: string + /** OpenList 管理员账号(用于 testConnection) */ + 'openlist.username'?: string + /** OpenList 密码 */ + 'openlist.password'?: string + /** OpenList 用户令牌(与账号密码二选一) */ + 'openlist.token'?: string + /** OpenList 中 lx-music 备份根目录 */ + 'openlist.root'?: string + /** 上传 OpenList 后是否保留本地文件 */ + 'openlist.keepLocalAfterUpload'?: boolean + /** 远端可用性校验周期(分钟) */ + 'openlist.verifyInterval'?: number + /** 双向同步周期(分钟) */ + 'openlist.syncInterval'?: number + /** 各用户备份目录配置(JSON 数组) */ + 'openlist.userBackups'?: string + /** 是否同步非音频文件(图片/封面等) */ + 'openlist.includeNonAudio'?: boolean + /** OpenList 外网可访问地址(默认同 openlist.endpoint)。用于把网盘/代理直链回传给客户端;填写时结尾可带或不带斜杠,后台规范化 */ + 'openlist.externalUrl'?: string } } From b20d33b3202888a8ceadf18cea780a749c75bf9f Mon Sep 17 00:00:00 2001 From: pavel2 Date: Thu, 29 Oct 2026 16:01:17 +0800 Subject: [PATCH 03/34] feat(openlist): auto-enable Subsonic OpenList cache-first for upload-delete backups When a per-user OpenList backup uses the upload-delete mode, the local cache index is purged after a verified upload, so the next playback has no local cache. To keep OpenList as the permanent cache (instead of re-downloading from source), the Subsonic 'cache-first use OpenList' switch is now auto-enabled on save. UI (app.js): warn when a backup mode is switched to upload-delete, and persist subsonic.playCacheFirstUseOpenlist=true together with the OpenList config. fileCache: the restore-from-OpenList path stays globally reachable because OpenList upload backup is itself globally triggered, keeping upload/restore symmetric. The openlist server module blobs are committed as clean UTF-8. --- package.json | 2 +- public/app.js | 33 +++++++++++++++++--- public/index.html | 11 ++++++- public/js/config.js | 4 +-- src/defaultConfig.ts | 1 + src/server/music/fileCache.ts | 46 +++++++++++++++++----------- src/server/openlistBackup.ts | Bin 80236 -> 46544 bytes src/server/openlistBackupDb.ts | Bin 37456 -> 19976 bytes src/server/openlistClient.ts | Bin 19192 -> 10152 bytes src/server/server.ts | 53 ++++++++++++++++++++++++++++----- src/types/config.d.ts | 2 ++ 11 files changed, 118 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index ee0c9f64..84724672 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lx-music-sync-server", - "version": "2.1.4.1", + "version": "2.1.4.2", "private": true, "scripts": { "prebuild": "npm run build:css && npm run build:files && node scripts/download-binary.js && node scripts/update-build-hash.js", diff --git a/public/app.js b/public/app.js index 95d30e04..c9ea921b 100644 --- a/public/app.js +++ b/public/app.js @@ -3626,6 +3626,7 @@ class App { form.elements['openlist.keepLocalAfterUpload'].checked = config['openlist.keepLocalAfterUpload'] !== false form.elements['openlist.externalUrl'].value = config['openlist.externalUrl'] || '' form.elements['openlist.includeNonAudio'].checked = config['openlist.includeNonAudio'] === true + form.elements['openlist.backupSystemConfig'].checked = config['openlist.backupSystemConfig'] === true form.elements['openlist.verifyInterval'].value = config['openlist.verifyInterval'] || 5 this.toggleOpenlistVisibility() form.elements['openlist.enable'].onchange = () => this.toggleOpenlistVisibility() @@ -3647,6 +3648,7 @@ class App { const form = document.getElementById('openlist-form') if (!form) return const fd = new FormData(form) + const userBackupsRaw = this.collectOpenlistUserBackups() const cfg = { 'openlist.enable': fd.get('openlist.enable') === 'on', 'openlist.endpoint': (fd.get('openlist.endpoint') || '').trim(), @@ -3654,10 +3656,19 @@ class App { 'openlist.root': (fd.get('openlist.root') || '').trim() || '/tyCloud', 'openlist.keepLocalAfterUpload': fd.get('openlist.keepLocalAfterUpload') === 'on', 'openlist.includeNonAudio': fd.get('openlist.includeNonAudio') === 'on', + 'openlist.backupSystemConfig': fd.get('openlist.backupSystemConfig') === 'on', 'openlist.externalUrl': (fd.get('openlist.externalUrl') || '').trim(), 'openlist.verifyInterval': parseInt(fd.get('openlist.verifyInterval')) || 5, - 'openlist.userBackups': this.collectOpenlistUserBackups(), + 'openlist.userBackups': userBackupsRaw, } + // 若任一用户备份模式为「上传后删除」,自动开启 Subsonic「缓存优先时优先使用 OpenList」, + // 避免本地被删后下次播放回源重下,而是直接命中 OpenList 直链(需 Subsonic「缓存优先」也已开启)。 + try { + const arr = JSON.parse(userBackupsRaw) + if (Array.isArray(arr) && arr.some(b => b && b.mode === 'upload-delete')) { + cfg['subsonic.playCacheFirstUseOpenlist'] = true + } + } catch {} try { await this.request('/api/config', { method: 'POST', body: JSON.stringify(cfg) }) if (window.showSuccess) window.showSuccess('OpenList 配置已保存') @@ -3700,8 +3711,9 @@ class App { selected.forEach(u => { const d = document.getElementById('openlist-dl-sw-' + u) const c = document.getElementById('openlist-ca-sw-' + u) + const s = document.getElementById('openlist-snap-sw-' + u) const m = document.getElementById('openlist-mode-' + u) - prev[u] = { download: d ? d.checked : null, cache: c ? c.checked : null, mode: m ? m.value : '' } + prev[u] = { download: d ? d.checked : null, cache: c ? c.checked : null, snapshot: s ? s.checked : null, mode: m ? m.value : '' } }) const modeOptions = (sel) => ([ ['upload', '上传(保留本地)'], @@ -3719,26 +3731,39 @@ class App { const tpl = map[u] || {} const dlOn = (prev[u] && prev[u].download !== null) ? prev[u].download : (tpl.backupDownload !== false) const caOn = (prev[u] && prev[u].cache !== null) ? prev[u].cache : (tpl.backupCache !== false) + const snOn = (prev[u] && prev[u].snapshot !== null) ? prev[u].snapshot : (tpl.backupSnapshot !== false) const md = (prev[u] && prev[u].mode) ? prev[u].mode : (tpl.mode || 'upload') return '
' + '' + '
' + '
' + toggleHtml('openlist-dl-sw-' + u, dlOn) + '
' + '
' + toggleHtml('openlist-ca-sw-' + u, caOn) + '
' + + '
' + toggleHtml('openlist-snap-sw-' + u, snOn) + '
' + '
' + - '
' + + '
' + '
' }).join('') } + /** 用户在 OpenList 备份里把某用户模式改为「上传后删除本地」时给出强提示: + * 该模式会清除本地缓存索引,下次播放将无本地缓存;为保证直接命中 OpenList 直链(而非回源重下), + * 保存时会自动开启 Subsonic 的「缓存优先时优先使用 OpenList」(见 saveOpenlistConfig)。 */ + onOpenlistModeChange(sel) { + if (!sel || sel.value !== 'upload-delete') return + if (window.showInfo) { + window.showInfo('已选择「上传后删除本地」:该模式会在上传完成后清除本地缓存索引,下次播放将无本地缓存。\n保存后系统会自动开启 Subsonic「缓存优先时优先使用 OpenList」,以便直接命中 OpenList 直链而非回源重下。\n(该自动开启仅在 Subsonic「服务器存在缓存优先播放」也已开启时生效)') + } + } + collectOpenlistUserBackups() { const hidden = document.querySelector('input[name="openlist.userBackupUsers"]') const selected = (hidden && hidden.value ? hidden.value.split(',').map(s => s.trim()).filter(Boolean) : []) return JSON.stringify(selected.map(u => { const d = document.getElementById('openlist-dl-sw-' + u) const c = document.getElementById('openlist-ca-sw-' + u) + const s = document.getElementById('openlist-snap-sw-' + u) const m = document.getElementById('openlist-mode-' + u) - return { user: u, enabled: true, backupDownload: d ? !!d.checked : true, backupCache: c ? !!c.checked : true, mode: (m ? m.value : 'upload') } + return { user: u, enabled: true, backupDownload: d ? !!d.checked : true, backupCache: c ? !!c.checked : true, backupSnapshot: s ? !!s.checked : true, mode: (m ? m.value : 'upload') } })) } diff --git a/public/index.html b/public/index.html index 2122fe23..66e0fb67 100644 --- a/public/index.html +++ b/public/index.html @@ -2382,7 +2382,7 @@

OpenList 连接

- 启用 OpenList 音频备份 ? + 启用Openlist同步 ?
+
+
+ 同步系统配置到 OpenList ? +
+ +
diff --git a/public/js/config.js b/public/js/config.js index 782edad1..227aedec 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -2,6 +2,6 @@ // 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts) // 服务端拦截 /js/config.js 请求, 使用 package.json 版本号并合并服务端配置后返回 window.CONFIG = { - buildHash: '4b35e04', - version: 'v2.1.4.1', + buildHash: '93ae45e', + version: 'v2.1.4.2', }; diff --git a/src/defaultConfig.ts b/src/defaultConfig.ts index 3f710f7b..ec93eb62 100644 --- a/src/defaultConfig.ts +++ b/src/defaultConfig.ts @@ -139,6 +139,7 @@ const config: LX.Config = { 'openlist.userBackups': '[]', // 各用户备份目录配置(JSON 数组) 'openlist.includeNonAudio': false, // 是否同步非音频文件(图片/封面等) 'openlist.externalUrl': '', // OpenList 外网地址(留空则同 openlist.endpoint) + 'openlist.backupSystemConfig': false, // 是否把系统配置(config.js)同步备份到 OpenList } export default config diff --git a/src/server/music/fileCache.ts b/src/server/music/fileCache.ts index ee76e9dd..8543fb52 100644 --- a/src/server/music/fileCache.ts +++ b/src/server/music/fileCache.ts @@ -2,8 +2,7 @@ import { resolveContainedPath } from '../auth/security' import { serveFile } from '../http/serveFile' import { getCacheBrowseLocation } from './cacheBrowseLocation' import { isRemasterStage, remasterStaging } from '../task/remasterStaging' -import { onFileCached as openlistBackupOnFileCached, tryRestoreFromOpenlist } from '@/server/openlistBackup' -import { softDeleteLocalFileByPath, getActiveBackupLocalPaths } from '@/server/openlistBackupDb' + import fs from 'fs' @@ -2215,6 +2214,16 @@ export const downloadAndCache = (...args: Parameters { if (downloadJobs.get(key)?.promise === job) downloadJobs.delete(key) }) + // 文件成功落盘后,发一个与 openlist 无关的通用事件,供各模块(如 openlist 备份)订阅 + job.then(() => { + const lx = (globalThis as any).lx + if (lx?.fileCachedListeners?.length) { + const info = { username, quality: quality || 'unknown', dir } + for (const fn of lx.fileCachedListeners) { + try { fn(info) } catch (e) { console.error('[fileCache] fileCached listener error:', e) } + } + } + }).catch(() => {}) downloadJobs.set(key, { promise: job, signature }) return job } @@ -2344,11 +2353,14 @@ const downloadAndCacheImpl = async (songInfo: any, url: string, quality?: string // Q1:缓存未命中时先查 OpenList 是否已有该文件的 verified 备份,有则直接取回, // 避免回源平台重复下载并重新上传(保留平台来源字段,仅用 origin 标记回写管道)。 - // 该路径服务「原生客户端 / Web」等走 lx-server 自有接口的请求,与 Subsonic 协议开关解耦: - // 仅由 openlist 自身开关(enable + 用户备份配置)控制,不受 subsonic.playCacheFirst* 影响。 + // 该路径对「原生客户端 / Web / Subsonic」等走 lx-server 的请求统一生效—— + // 因为 OpenList 上传备份本身也是全局触发(见 downloadAndCacheImpl 的 fileCachedUploadListeners), + // 故「优先使用 OpenList 取回」不限定 Subsonic。是否启用由 openlist 自身开关(enable + 用户备份配置)决定, + // 并受 subsonic.playCacheFirst / subsonic.playCacheFirstUseOpenlist 控制(见 isOpenlistCacheFirstEnabled)。 if (username) { const targetFolder: 'cache' | 'music' = isOnlyDownload ? 'music' : 'cache' - const restored = await tryRestoreFromOpenlist(username, targetFolder, dir, baseName, quality || 'unknown') + const restoreResolver = (globalThis as any).lx?.cacheRestoreResolver + const restored = restoreResolver ? await restoreResolver(username, targetFolder, dir, baseName, quality || 'unknown') : null if (restored) { cacheProgress.set(songKey, { progress: 100, status: 'finished', total: restored.size, received: restored.size }) expireProgress(songKey) @@ -2591,17 +2603,15 @@ const downloadAndCacheImpl = async (songInfo: any, url: string, quality?: string nativeIds: extractNativeIds(songInfo) }, folderType) - // [OpenList 备份] 音频落盘后触发备份(按 per-user 配置判定是否上传) - if ((globalThis as any).lx?.config?.['openlist.enable']) { - try { - openlistBackupOnFileCached(normalizedUsername, folderType, finalPath, { - source: metadata.source, - songmid: id, - quality: actualQuality, - origin: 'platform', - }) - } catch (e) { - console.warn('[openlist-backup] 落盘钩子异常:', e) + // [OpenList 备份] 音频落盘后,发中性事件由 openlist 模块订阅并判定是否上传 + const lxU = (globalThis as any).lx + if (lxU?.fileCachedUploadListeners?.length) { + for (const fn of lxU.fileCachedUploadListeners) { + try { + fn({ user: normalizedUsername, folderType, localPath: finalPath, meta: { source: metadata.source, songmid: id, quality: actualQuality, origin: 'platform' } }) + } catch (e) { + console.warn('[fileCache] fileCachedUpload listener error:', e) + } } } @@ -3014,7 +3024,7 @@ export const clearAllCache = (username?: string) => { const stats = fs.statSync(path.join(dir, file)) const fp = path.join(dir, file) fs.unlinkSync(fp) - softDeleteLocalFileByPath(fp) + ;(globalThis as any).lx?.softDeleteLocalFileByPath?.(fp) deletedCount++; freedSize += stats.size } catch (e) { } } @@ -3182,7 +3192,7 @@ export const checkAndCleanupCache = async (username?: string) => { allFiles.sort((a, b) => a.mtime - b.mtime) // 跳过「仍有活跃备份记录」的文件,避免本地缓存被清理后 OpenList 上传因源文件不存在而失败 let activeBackupPaths: Set | null = null - try { activeBackupPaths = getActiveBackupLocalPaths() } catch { activeBackupPaths = null } + try { activeBackupPaths = (globalThis as any).lx?.getActiveBackupLocalPaths?.() ?? null } catch { activeBackupPaths = null } let currentSize = totalSize const targetSize = limitBytes * 0.95 let deletedCount = 0 diff --git a/src/server/openlistBackup.ts b/src/server/openlistBackup.ts index f2c9f5b929c8de095b2fab284fb8950c2110d9f8..20e1e816478003b6942ecd36a31d6581f64af549 100644 GIT binary patch literal 46544 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 z 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 mnS&bCQ@o{E3viXA1$sNA4m+kq4D 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 b$!x=B^C5e5jd2z4bBIZSt2AG46o>sETsbGW diff --git a/src/server/openlistClient.ts b/src/server/openlistClient.ts index 57c05dd6216d9e2ca045983dd28711c75cea094c..4296003729e6840e7c58c010b4f720483821342d 100644 GIT binary patch literal 10152 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 literal 19192 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 { @@ -61,6 +61,17 @@ async function validateOpenListConfig(newConfig: any): Promise { } } } + +/** 配置保存/文件生成后,防抖触发一次 OpenList 同步(后台执行,不阻塞请求响应) */ +let pendingOpenlistSyncTimer: NodeJS.Timeout | null = null +function scheduleOpenlistSync(): void { + if (pendingOpenlistSyncTimer) clearTimeout(pendingOpenlistSyncTimer) + pendingOpenlistSyncTimer = setTimeout(() => { + pendingOpenlistSyncTimer = null + if (!global.lx.config['openlist.enable']) return + void runSyncNow({}).catch(() => {}) + }, 8000) +} import * as customSourceHandlers from './sources/customSourceHandlers' import * as fileCache from './music/fileCache' import { withCacheBrowseLocation } from './music/cacheBrowseLocation' @@ -7274,6 +7285,19 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi 'configBackup.retentionDays': global.lx.config['configBackup.retentionDays'] ?? 7, 'configBackup.dir': global.lx.config['configBackup.dir'] ?? '', 'snapshot.backupPath': global.lx.config['snapshot.backupPath'] ?? '', + 'openlist.enable': global.lx.config['openlist.enable'] ?? false, + 'openlist.endpoint': global.lx.config['openlist.endpoint'] || '', + 'openlist.username': global.lx.config['openlist.username'] || '', + 'openlist.password': global.lx.config['openlist.password'] || '', + 'openlist.token': global.lx.config['openlist.token'] || '', + 'openlist.root': global.lx.config['openlist.root'] || '/lx_music', + 'openlist.keepLocalAfterUpload': global.lx.config['openlist.keepLocalAfterUpload'] !== false, + 'openlist.verifyInterval': global.lx.config['openlist.verifyInterval'] || 5, + 'openlist.syncInterval': global.lx.config['openlist.syncInterval'] || 60, + 'openlist.userBackups': global.lx.config['openlist.userBackups'] || '[]', + 'openlist.includeNonAudio': global.lx.config['openlist.includeNonAudio'] === true, + 'openlist.externalUrl': global.lx.config['openlist.externalUrl'] || global.lx.config['openlist.endpoint'] || '', + 'openlist.backupSystemConfig': global.lx.config['openlist.backupSystemConfig'] === true, subsonicPortConflict: global.lx.subsonicPortConflict || null, configFilePath: global.lx.configPath || process.env.CONFIG_PATH || path.join(global.lx.dataPath, 'config.js'), } @@ -7433,13 +7457,15 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } } - // [OpenList 配置] 先实测校验可用性(强校验),再应用 - try { - await validateOpenListConfig(newConfig) - } catch (ve: any) { - res.writeHead(422, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: false, error: 'OpenList 配置校验失败:' + (ve?.message || ve) })) - return + // [OpenList 配置] 仅当本次提交含 openlist 字段时才做连通性校验; + // 校验失败不再阻断保存(配置照存),仅作为 warning 提示,避免 OpenList 临时不可达时所有配置都保存不了 + const hasOpenlistChange = Object.keys(newConfig).some(k => k === 'openlist.enable' || k.startsWith('openlist.')) + if (hasOpenlistChange) { + try { + await validateOpenListConfig(newConfig) + } catch (ve: any) { + warning = (warning ? warning + ';' : '') + 'OpenList 配置校验未通过(已保存,但同步可能因不可达而失败):' + (ve?.message || ve) + } } if (newConfig['openlist.enable'] !== undefined) global.lx.config['openlist.enable'] = newConfig['openlist.enable'] if (newConfig['openlist.endpoint'] !== undefined) global.lx.config['openlist.endpoint'] = newConfig['openlist.endpoint'] @@ -7453,6 +7479,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi if (newConfig['openlist.userBackups'] !== undefined) global.lx.config['openlist.userBackups'] = newConfig['openlist.userBackups'] if (newConfig['openlist.includeNonAudio'] !== undefined) global.lx.config['openlist.includeNonAudio'] = newConfig['openlist.includeNonAudio'] if (newConfig['openlist.externalUrl'] !== undefined) global.lx.config['openlist.externalUrl'] = String(newConfig['openlist.externalUrl']).replace(/[\\/]+$/, '') + if (newConfig['openlist.backupSystemConfig'] !== undefined) global.lx.config['openlist.backupSystemConfig'] = newConfig['openlist.backupSystemConfig'] if (newConfig['snapshot.backupPath'] !== undefined) { try { @@ -7648,6 +7675,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi 'openlist.syncInterval': global.lx.config['openlist.syncInterval'] || 60, 'openlist.userBackups': global.lx.config['openlist.userBackups'] || '[]', 'openlist.includeNonAudio': global.lx.config['openlist.includeNonAudio'] === true, + 'openlist.backupSystemConfig': global.lx.config['openlist.backupSystemConfig'] === true, 'singer.sourcePriority': global.lx.config['singer.sourcePriority'], 'artist.maxFetchPages': global.lx.config['artist.maxFetchPages'], 'cache.namingPattern': global.lx.config['cache.namingPattern'], @@ -7676,6 +7704,11 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi void global.lx.webdavSync.syncChangedFiles() } + // 配置保存后:若启用 OpenList 同步,后台(防抖)触发一次同步 + if (global.lx.config['openlist.enable']) { + scheduleOpenlistSync() + } + res.writeHead(200) res.end(JSON.stringify({ success: true, warning })) } catch (e) { @@ -9126,6 +9159,10 @@ const startSubsonicStandaloneServer = () => { // } export const startServer = async (port: number, ip: string) => { + // 注册 OpenList 自动同步钩子(供配置保存 / 下载落盘等处调用) + ;(global.lx as any).scheduleOpenlistSync = scheduleOpenlistSync + // 订阅 fileCache 的通用「文件落盘」事件,落盘即触发防抖同步(与 fileCache 解耦) + registerFileCacheSyncHook() // 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 153fb36b..e731f694 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -552,5 +552,7 @@ declare namespace LX { 'openlist.includeNonAudio'?: boolean /** OpenList 外网可访问地址(默认同 openlist.endpoint)。用于把网盘/代理直链回传给客户端;填写时结尾可带或不带斜杠,后台规范化 */ 'openlist.externalUrl'?: string + /** 是否把系统配置(config.js)同步备份到 OpenList(全局开关,开启后每次同步会上传 config.js 到 /_system/config.js) */ + 'openlist.backupSystemConfig'?: boolean } } From 313f7057d8899b95f43201bd539df14a24c4a8ab Mon Sep 17 00:00:00 2001 From: pavel2 Date: Thu, 29 Oct 2026 16:58:49 +0800 Subject: [PATCH 04/34] =?UTF-8?q?chore(openlist):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=BA=90=E7=A0=81=E6=96=87=E4=BB=B6=20UTF-8=20BOM=20=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/openlistBackup.ts | 2 +- src/server/openlistBackupDb.ts | 2 +- src/server/openlistClient.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/openlistBackup.ts b/src/server/openlistBackup.ts index 20e1e816..50d95671 100644 --- a/src/server/openlistBackup.ts +++ b/src/server/openlistBackup.ts @@ -1,4 +1,4 @@ -/** +/** * OpenList 音频备份 / 同步管理器 * 衔接:fileCache 落盘钩子 -> 按 per-user 配置判定是否上传 -> 上传(OpenListClient) -> 写入 SQLite。 * diff --git a/src/server/openlistBackupDb.ts b/src/server/openlistBackupDb.ts index ca6286e3..05993d0c 100644 --- a/src/server/openlistBackupDb.ts +++ b/src/server/openlistBackupDb.ts @@ -1,4 +1,4 @@ -/** +/** * OpenList 音频备份 - SQLite 元数据库 * 记录每个待备份/已备份音频文件的本地路径、远端路径与上传状态。 * 状态机:pending -> uploading -> completed -> verified(失败记 failed)。 diff --git a/src/server/openlistClient.ts b/src/server/openlistClient.ts index 42960037..00bf960e 100644 --- a/src/server/openlistClient.ts +++ b/src/server/openlistClient.ts @@ -1,4 +1,4 @@ -/** +/** * OpenList / alist 存储客户端(音频备份专用)。 * 契约已对真实实例(天翼云盘 tyCloud 挂载)实测确认: * - 登录 POST /api/auth/login -> data.token From 519c18dce820376042d734eee67ea1bdd0ca8f02 Mon Sep 17 00:00:00 2001 From: pavel2 Date: Thu, 29 Oct 2026 17:45:20 +0800 Subject: [PATCH 05/34] =?UTF-8?q?ci(docker):=20=E6=81=A2=E5=A4=8D=20dev/ma?= =?UTF-8?q?in=20=E5=88=86=E6=94=AF=20push=20=E8=A7=A6=E5=8F=91=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3600be91..05eae1ed 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -3,6 +3,8 @@ name: Build and Push Docker Image on: workflow_call: workflow_dispatch: + push: + branches: [dev, main] env: REGISTRY: ghcr.io # 使用 github.repository_owner 获取用户名,并指定镜像名为 lxserver From 76266044775be8e52601c7fc80c5addc9fc5fe13 Mon Sep 17 00:00:00 2001 From: pavel2 Date: Fri, 30 Oct 2026 14:00:24 +0800 Subject: [PATCH 06/34] =?UTF-8?q?ci:=20trigger=20dev=20build=20(CodeRabbit?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8D=E5=B7=B2=E5=90=88=E5=85=A5=20dev)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From b812d7803281cb866c2626437fd30766c5b04643 Mon Sep 17 00:00:00 2001 From: pavel2 Date: Fri, 30 Oct 2026 14:10:57 +0800 Subject: [PATCH 07/34] =?UTF-8?q?ci:=20=E4=BB=85=E6=8E=A8=E9=80=81=20ghcr.?= =?UTF-8?q?io=EF=BC=8C=E7=A7=BB=E9=99=A4=E7=BC=BA=E5=A4=B1=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E7=9A=84=20Docker=20Hub=20=E7=99=BB=E5=BD=95=E6=AD=A5?= =?UTF-8?q?=E9=AA=A4=EF=BC=88=E4=BF=AE=E5=A4=8D=20build=5Fand=5Fpush=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 05eae1ed..ef79eef6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -9,8 +9,6 @@ env: REGISTRY: ghcr.io # 使用 github.repository_owner 获取用户名,并指定镜像名为 lxserver IMAGE_NAME: ${{ github.repository_owner }}/lxserver - # Docker Hub 仓库名 - DOCKER_HUB_REPO: ${{ secrets.DOCKER_USERNAME }}/lxserver jobs: build_and_push: @@ -37,12 +35,6 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - # Extract project version from package.json - name: Read version from config id: get_version @@ -51,6 +43,7 @@ jobs: echo "PROJECT_VERSION=$VERSION" >> $GITHUB_ENV - name: Generate and Commit Build Hash + if: github.event_name != 'push' run: | node scripts/update-build-hash.js git config --global user.name "github-actions[bot]" @@ -66,7 +59,6 @@ jobs: with: images: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - ${{ env.DOCKER_HUB_REPO }} tags: | type=raw,value=latest type=raw,value=${{ env.PROJECT_VERSION }} From d932dc3ba9b561d28948ad7bf2be82a152c199f8 Mon Sep 17 00:00:00 2001 From: pavel2 Date: Fri, 30 Oct 2026 14:16:29 +0800 Subject: [PATCH 08/34] =?UTF-8?q?ci:=20ghcr.io=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20GHCR=5FTOKEN=EF=BC=88=E4=B8=8E=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E8=BF=90=E8=A1=8C=2039806164426=20=E4=B8=80=E8=87=B4?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=20build/push=20=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E8=A2=AB=E6=8B=92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ef79eef6..1d1ee930 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -33,7 +33,7 @@ jobs: with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + password: ${{ secrets.GHCR_TOKEN }} # Extract project version from package.json - name: Read version from config From e4490e6c9b2eb7b0df878cad4e463118cab6ebf9 Mon Sep 17 00:00:00 2001 From: pavel2 Date: Wed, 4 Nov 2026 22:23:41 +0800 Subject: [PATCH 09/34] =?UTF-8?q?feat(backup):=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=A4=87=E4=BB=BD=E6=94=AF=E6=8C=81=E6=AF=8F?= =?UTF-8?q?N=E5=B0=8F=E6=97=B6+=E9=94=9A=E7=82=B9=E6=97=B6=E9=97=B4(?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=AF=8F=E5=A4=A90=E7=82=B9)=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=98=BE=E7=A4=BA=E4=B8=8B=E6=AC=A1/=E4=B8=8A?= =?UTF-8?q?=E6=AC=A1=E5=A4=87=E4=BB=BD=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/app.js | 22 ++++++++++++++++ public/index.html | 22 ++++++++++++++++ src/index.ts | 61 ++++++++++++++++++++++++++++++++++++------- src/server/server.ts | 32 ++++++++++++++++++++--- src/types/app.d.ts | 3 +++ src/types/config.d.ts | 12 +++++++++ 6 files changed, 138 insertions(+), 14 deletions(-) diff --git a/public/app.js b/public/app.js index 6aac9a58..1723a776 100644 --- a/public/app.js +++ b/public/app.js @@ -2716,6 +2716,12 @@ class App { if (form.elements['configBackup.dir']) { form.elements['configBackup.dir'].value = config['configBackup.dir'] || ''; } + if (form.elements['configBackup.intervalHours']) { + form.elements['configBackup.intervalHours'].value = config['configBackup.intervalHours'] || 24; + } + if (form.elements['configBackup.scheduleTime']) { + form.elements['configBackup.scheduleTime'].value = config['configBackup.scheduleTime'] || '00:00'; + } if (form.elements['snapshot.backupPath']) { form.elements['snapshot.backupPath'].value = config['snapshot.backupPath'] || ''; } @@ -3203,6 +3209,8 @@ class App { ...this.collectBackupPolicy(), 'configBackup.enable': formData.get('configBackup.enable') === 'on', 'configBackup.retentionDays': parseInt(formData.get('configBackup.retentionDays')) || 7, + 'configBackup.intervalHours': parseInt(formData.get('configBackup.intervalHours')) || 24, + 'configBackup.scheduleTime': (formData.get('configBackup.scheduleTime') || '').trim(), 'configBackup.dir': (formData.get('configBackup.dir') || '').trim(), 'snapshot.backupPath': (formData.get('snapshot.backupPath') || '').trim(), 'admin.path': adminPath, @@ -5127,6 +5135,20 @@ class App { dirText.textContent = data.backupDir || 'backups'; dirText.title = data.backupDir || 'backups'; } + const nextText = document.getElementById('config-backup-next-text'); + if (nextText) { + if (data.autoBackupEnabled && data.nextBackupAt) { + nextText.textContent = new Date(data.nextBackupAt).toLocaleString(); + } else if (data.autoBackupEnabled) { + nextText.textContent = '计算中…'; + } else { + nextText.textContent = '已停用'; + } + } + const lastText = document.getElementById('config-backup-last-text'); + if (lastText) { + lastText.textContent = data.lastBackupAt ? new Date(data.lastBackupAt).toLocaleString() : '暂无'; + } if (!list.length) { container.innerHTML = '
暂无系统配置备份文件。可点击上方【立即备份配置】手动创建第一份备份。
'; diff --git a/public/index.html b/public/index.html index 115d12e0..9d56bd16 100644 --- a/public/index.html +++ b/public/index.html @@ -1196,6 +1196,20 @@

备份与恢复

+
+
+ + + 每 N 小时自动备份一次 +
+
+ + + 留空=进程启动后每 N 小时;填 HH:MM 则在该时刻首次触发,之后每 N 小时循环 +
+
+
备份与恢复管理
backups
+
+
下一次自动备份
+
—
+
+
+
上次自动备份
+
—
+
' + '' }).join('') const pager = '
' + @@ -4066,7 +4072,6 @@ class App { '异常原因' + '更新时间' + '操作' + - '云端路径' + '' + rows + '
' + pager this.updateOlbSelCount() } catch (e) { @@ -4090,6 +4095,63 @@ class App { } } + async retrySelectedOpenlist() { + if (!this._olbSelected || !this._olbSelected.size) return + const ids = Array.from(this._olbSelected) + if (!confirm('确认对选中的 ' + ids.length + ' 条记录提交批量重试?(失败/异常状态的记录将重新上传或验证)')) return + try { + const r = await this.request('/api/openlist/retry-batch', { method: 'POST', body: JSON.stringify({ ids }) }) + if (r && r.success) { + if (window.showInfo) window.showInfo('已提交 ' + (r.retried || 0) + ' 条重试' + ((r.failed ? (',' + r.failed + ' 条失败') : ''))) + this.loadOpenlistBackups(true) + } else if (window.showError) { + window.showError('批量重试失败: ' + ((r && (r.error || r.message)) || '未知错误')) + } + } catch (e) { + console.error(e) + if (window.showError) window.showError('批量重试失败: ' + (e && e.message || e)) + } + } + + showOpenlistDetail(id) { + const list = this._olbCurrentList || [] + const f = list.find(x => x.id === id) + if (!f) { if (window.showError) window.showError('未找到该记录详情'); return } + const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + const fmtSize = (n) => { n = Number(n) || 0; if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB' } + const row = (k, v) => '' + esc(k) + '' + (v === '' || v == null ? '—' : esc(v)) + '' + const html = '' + + row('记录 ID', f.id) + + row('用户', f.user) + + row('类型', f.folder_type) + + row('文件名', f.name) + + row('状态', f.status) + + row('大小', fmtSize(f.size)) + + row('来源平台', f.source) + + row('音质', f.quality) + + row('歌曲 ID', f.songmid) + + row('本地存在', f.local_exists ? '是' : '否') + + row('重试次数', f.retry_count) + + row('本地路径', f.local_path) + + row('云端路径', f.remote_path) + + row('远端大小', f.remote_size) + + row('本地哈希', f.local_hash) + + row('更新时间', f.updated_at) + + row('错误信息', f.error) + + row('缓存元数据', f.cache_metadata ? JSON.stringify(JSON.parse(f.cache_metadata || '{}'), null, 2) : '') + + '
' + const modal = document.getElementById('modal') + const title = document.getElementById('modal-title') + const body = document.getElementById('modal-body') + if (modal && title && body) { + title.textContent = '记录详情 #' + f.id + (f.name ? ' · ' + f.name : '') + body.innerHTML = html + modal.classList.remove('hidden') + } else { + alert('记录 #' + f.id + '\n用户: ' + f.user + '\n类型: ' + f.folder_type + '\n状态: ' + f.status + '\n云端路径: ' + (f.remote_path || '') + '\n本地路径: ' + (f.local_path || '')) + } + } + toggleOpenlistSelect(el) { const id = parseInt(el.dataset.id) if (!this._olbSelected) this._olbSelected = new Set() @@ -4114,6 +4176,8 @@ class App { if (span) span.textContent = n ? ('已选 ' + n + ' 项') : '' const btn = document.getElementById('olb-bulkdel') if (btn) btn.disabled = n === 0 + const rbtn = document.getElementById('olb-bulkretry') + if (rbtn) rbtn.disabled = n === 0 } async deleteSelectedOpenlist() { @@ -4137,8 +4201,12 @@ class App { switchOpenlistTab(name) { const map = { config: ['tab-btn-openlist-config', 'openlist-pane-config'], - records: ['tab-btn-openlist-records', 'openlist-pane-records'], + media: ['tab-btn-openlist-media', 'openlist-pane-records'], + system: ['tab-btn-openlist-system', 'openlist-pane-records'], + snapshot: ['tab-btn-openlist-snapshot', 'openlist-pane-records'], } + const scopeMap = { media: 'cache,music', system: 'system', snapshot: 'list' } + this._olbFolderScope = scopeMap[name] || '' for (const [btnId, paneId] of Object.values(map)) { const b = document.getElementById(btnId); if (b) b.classList.remove('active') const p = document.getElementById(paneId); if (p) p.classList.remove('active') @@ -4146,7 +4214,7 @@ class App { const [btnId, paneId] = map[name] || map.config const b = document.getElementById(btnId); if (b) b.classList.add('active') const p = document.getElementById(paneId); if (p) p.classList.add('active') - if (name === 'records') this.loadOpenlistBackups() + if (paneId === 'openlist-pane-records') this.loadOpenlistBackups() } scheduleOpenlistReload() { diff --git a/public/index.html b/public/index.html index bd134cca..852b490a 100644 --- a/public/index.html +++ b/public/index.html @@ -2434,7 +2434,7 @@

备份与恢复管理

备份配置 - + +
@@ -2624,7 +2639,7 @@

OpenList 连接

- 同步状态与文件记录 + 同步记录(媒体文件 / 系统配置 / 歌单快照) ?

实时查看文件备份进度、验证状态及失败重试

@@ -2642,6 +2657,13 @@

立即全量同步 + + ${item.kind !== 'dislike' ? ` + ` : ''} `; + }).join(''); + bar.querySelectorAll('.sk-tab').forEach(btn => { + btn.addEventListener('click', () => { + this._snapshotKind = btn.dataset.kind; + bar.querySelectorAll('.sk-tab').forEach(b => { + const on = b.dataset.kind === this._snapshotKind; + b.style.background = on ? 'var(--accent,#3b82f6)' : 'transparent'; + b.style.color = on ? '#fff' : 'inherit'; + }); + this.loadSnapshots(); + }); + }); + container.parentElement.insertBefore(bar, container); + } + triggerUploadSnapshot() { const username = document.getElementById('snapshot-user-select')?.value; if (!username) { diff --git a/public/js/config.js b/public/js/config.js index cc937124..ea93bf91 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -3,5 +3,5 @@ // 服务端拦截 /js/config.js 请求, 使用 package.json 版本号并合并服务端配置后返回 window.CONFIG = { buildHash: '2d4232f', - version: 'v2.1.4.10', + version: 'v2.1.4.11', }; From dce64fb21eb8f47e7253799af4198687bea0a952 Mon Sep 17 00:00:00 2001 From: pavel2 Date: Thu, 5 Nov 2026 17:11:30 +0800 Subject: [PATCH 26/34] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E3=80=8C?= =?UTF-8?q?=E4=B8=8D=E5=96=9C=E6=AC=A2=E3=80=8D=E5=BF=AB=E7=85=A7=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=AF=BC=E5=87=BA=E4=B8=BA=E7=A9=BA=20playList=5Fv2?= =?UTF-8?q?=20=E7=9A=84=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下载按钮此前对所有快照无条件套用 playList_v2 转换:dislike 数据({dislikeList:[...]})的 defaultList/loveList/userList 均为 undefined,导致导出成空壳 default/love 列表,用户误以为 dislike 无歌曲信息。 现 downloadSnapshot(id,kind) 按 kind 分流:dislike 直接导出原始规则(lx_dislike__.json),list 仍导出 playList_v2;按钮调用处传入 item.kind。version -> v2.1.4.12。 --- package.json | 2 +- public/app.js | 39 +++++++++++++++++++++++---------------- public/js/config.js | 2 +- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index af422445..4b3963f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lx-music-sync-server", - "version": "2.1.4.11", + "version": "2.1.4.12", "private": true, "scripts": { "prebuild": "npm run build:css && npm run build:files && node scripts/download-binary.js && node scripts/update-build-hash.js", diff --git a/public/app.js b/public/app.js index c291a3b2..09042f21 100644 --- a/public/app.js +++ b/public/app.js @@ -5388,7 +5388,7 @@ class App {
snapshot_${item.id}${item.kind === 'dislike' ? ' 不喜欢' : ''}
${this.formatFileSize(item.size)}
- - ${item.kind !== 'dislike' ? ` - ` : ''} +