From 4285677c9e72439eb60dff6757f4538bdf828d45 Mon Sep 17 00:00:00 2001 From: lxserver-bot Date: Thu, 11 Jun 2026 13:43:01 +0800 Subject: [PATCH 01/26] fix: enable lm-quick-search and sync quick search filter state --- public/music/js/local_music.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/music/js/local_music.js b/public/music/js/local_music.js index 6d7e4d7..3cf83b8 100644 --- a/public/music/js/local_music.js +++ b/public/music/js/local_music.js @@ -378,6 +378,8 @@ window.LocalMusicManager = { }; if (!matchKeywords(k)) return false; + if (qk && !matchKeywords(qk)) return false; + // SubPath check if (this.selectedSubPath !== '') { const target = this.selectedSubPath === '__ROOT__' ? '' : this.selectedSubPath; @@ -443,7 +445,7 @@ window.LocalMusicManager = { // 4. Update UI Indicator const dot = document.getElementById('lm-filter-active-dot'); - const hasActiveFilters = this.searchKeyword || this.filterQuality.size > 0 || this.filterFolder !== 'all' || this.filterStatus.size > 0 || this.filterSource.size > 0; + const hasActiveFilters = this.searchKeyword || this.quickSearchKeyword || this.filterQuality.size > 0 || this.filterFolder !== 'all' || this.filterStatus.size > 0 || this.filterSource.size > 0; if (dot) { if (hasActiveFilters) dot.classList.remove('hidden'); else dot.classList.add('hidden'); From 9ebb8e83795ff89ca8efd1949f28532379998b10 Mon Sep 17 00:00:00 2001 From: lxserver-bot Date: Thu, 11 Jun 2026 16:33:05 +0800 Subject: [PATCH 02/26] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=AD=8C=E5=8D=95=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 2 +- .gitignore | Bin 1204 -> 1220 bytes public/music/app.js | 123 ++++++++++++++++++++++ public/music/index.html | 20 ++++ "t update\357\200\242" | 225 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 "t update\357\200\242" diff --git a/.dockerignore b/.dockerignore index f23b589..f863e81 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,4 @@ data logs lx-music-desktop-master dist-electron -build +build \ No newline at end of file diff --git a/.gitignore b/.gitignore index 273ed134de80d3df96c190d721efc3b8920e4e50..e430177af75973acc9d755d6dce3968d4b5876d3 100644 GIT binary patch delta 24 fcmdnOd4zMr78ZddhEj%1h8%_z27Lxz1}+8wS^NcA delta 7 OcmX@YxrKAX78U>uy#n(9 diff --git a/public/music/app.js b/public/music/app.js index e742287..73374e0 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -116,6 +116,7 @@ const DEFAULT_SETTINGS = { playerBackground: 'blur', // 播放页背景: 'blur', 'solid', 'dark' saveAccountSettingsToFile: true, // 同步账号设置到文件 (默认开启) autoUpdateNetworkList: false, // 自动更新网络歌单 (默认关闭) + networkListAutoCheckInterval: '6h', // 网络歌单自动检测间隔 preferServerCache: true, // 优先播放缓存歌曲 (默认开启) remoteSyncUrl: '', // 远程同步地址 remoteSyncCode: '', // 远程同步连接码 @@ -145,6 +146,110 @@ try { console.error('[Settings] 加载设置失败:', e); } window.settings = settings; // 显式挂载到 window +window.networkListUpdateMap = new Set(); +let networkListAutoCheckTimer = null; + +function parseNetworkListAutoCheckInterval(value) { + if (value === undefined || value === null) return 0; + const raw = String(value).trim().toLowerCase(); + if (raw === '' || raw === '0' || raw === 'off' || raw === 'none' || raw === 'disable') return 0; + const matched = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/); + if (!matched) return null; + const count = parseFloat(matched[1]); + const unit = matched[2] || 'h'; + if (!Number.isFinite(count) || count < 0) return null; + switch (unit) { + case 'ms': return count; + case 's': return count * 1000; + case 'm': return count * 60 * 1000; + case 'h': return count * 60 * 60 * 1000; + case 'd': return count * 24 * 60 * 60 * 1000; + default: return null; + } +} + +function setupNetworkListAutoCheck() { + if (networkListAutoCheckTimer) { + clearInterval(networkListAutoCheckTimer); + networkListAutoCheckTimer = null; + } + if (!settings.autoUpdateNetworkList) { + return; + } + const intervalMs = parseNetworkListAutoCheckInterval(settings.networkListAutoCheckInterval); + if (intervalMs === null || intervalMs <= 0) { + return; + } + networkListAutoCheckTimer = setInterval(() => { + checkNetworkListUpdates().catch(err => console.error('[AutoCheck] 网络歌单检测失败:', err)); + }, intervalMs); + console.log('[AutoCheck] 已设置网络歌单自动检测间隔:', settings.networkListAutoCheckInterval, '(', intervalMs, 'ms )'); +} + +async function checkNetworkListUpdates(manual = false) { + if (!currentListData || !Array.isArray(currentListData.userList) || currentListData.userList.length === 0) { + if (manual && window.showToast) showToast('info', '当前没有可检查的网络歌单', 3000); + return; + } + + const targetLists = currentListData.userList.filter(l => l && l.sourceListId && l.source); + if (targetLists.length === 0) { + if (manual && window.showToast) showToast('info', '当前没有可检查的网络歌单', 3000); + return; + } + + const changedLists = []; + const failedLists = []; + + for (const list of targetLists) { + try { + const url = `${API_BASE}/songList/detail?source=${list.source}&id=${encodeURIComponent(list.sourceListId)}&page=1`; + const res = await fetch(url); + const data = await res.json(); + if (!data || !Array.isArray(data.list)) { + throw new Error('远端歌单数据不完整'); + } + + const remoteList = data.list.map(item => { + const formatted = formatSongToLxMusicStandard(item); + if (!formatted.source) formatted.source = list.source; + return formatted; + }); + + const localList = Array.isArray(list.list) ? list.list : []; + const sameLength = localList.length === remoteList.length; + const sameIds = sameLength && localList.every((item, index) => item && remoteList[index] && String(item.id || '') === String(remoteList[index].id || '') && String(item.source || '') === String(remoteList[index].source || '')); + if (!sameIds) { + window.networkListUpdateMap.add(list.id); + changedLists.push(list.name || list.id || list.sourceListId); + } else { + window.networkListUpdateMap.delete(list.id); + } + } catch (err) { + console.error('[CheckNetworkListUpdates] 检查失败:', list.name || list.id || list.sourceListId, err); + failedLists.push(list.name || list.id || list.sourceListId); + } + } + + if (typeof renderMyLists === 'function') { + renderMyLists(currentListData); + } + + if (manual) { + if (changedLists.length > 0) { + showSuccess(`检测到 ${changedLists.length} 个歌单已更新:${changedLists.join('、')}`); + } else { + showSuccess('所有网络歌单均为最新状态'); + } + if (failedLists.length > 0) { + showError(`部分歌单检测失败:${failedLists.join('、')}`); + } + } else if (changedLists.length > 0 && window.showToast) { + showToast('info', `检测到 ${changedLists.length} 个网络歌单有更新`, 5000); + } +} + +window.checkNetworkListUpdates = checkNetworkListUpdates; @@ -4934,6 +5039,7 @@ function loadSettings() { // 同步 UI 状态 syncSettingsUI(); + setupNetworkListAutoCheck(); } // ========== 键盘快捷键逻辑 ========== @@ -5092,6 +5198,13 @@ async function updateSetting(key, value) { } // 实时同步 UI 并应用效果 syncSettingsUI(key, value); + if (key === 'networkListAutoCheckInterval' || key === 'autoUpdateNetworkList') { + const intervalMs = parseNetworkListAutoCheckInterval(settings.networkListAutoCheckInterval); + if (key === 'networkListAutoCheckInterval' && intervalMs === null) { + showError('无效的自动检测间隔,请使用 30m / 6h / 1d 等格式'); + } + setupNetworkListAutoCheck(); + } // [New] Push to server if enabled if (settings.saveAccountSettingsToFile) { @@ -5256,6 +5369,7 @@ const SETTINGS_UI_MAP = { // 系统 & 网络 (System & Network) autoUpdateNetworkList: { id: 'setting-auto-update-list', type: 'checkbox' }, + networkListAutoCheckInterval: { id: 'setting-network-list-auto-check-interval', type: 'value' }, saveAccountSettingsToFile: { id: 'setting-save-settings-to-file', type: 'checkbox' }, enableLyricCache: { id: 'setting-enable-lyric-cache', type: 'checkbox' }, enableSongUrlCache: { id: 'setting-enable-url-cache', type: 'checkbox' }, @@ -8188,6 +8302,9 @@ function renderMyLists(data) { const showExternalOps = listObj && listObj.sourceListId && listObj.source; let opsHtml = ''; if (showExternalOps) { + const updateBadge = window.networkListUpdateMap && window.networkListUpdateMap.has(id) + ? `!` + : ''; opsHtml = ` + ${updateBadge} `; } @@ -8666,6 +8784,11 @@ async function handleRefreshList(listId, event, silent = false) { if (data.info.img || data.info.pic) list.Album = data.info.img || data.info.pic; } + // 清除该列表的更新标记 + if (window.networkListUpdateMap) { + window.networkListUpdateMap.delete(listId); + } + // 推送同步并重绘 UI await pushDataChange(); renderMyLists(currentListData); diff --git a/public/music/index.html b/public/music/index.html index c926125..573cc6f 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -1689,6 +1689,26 @@

自动更新

+
+
+
网络歌单自动检测间隔
+
输入值示例:30m / 6h / 1d,开启后自动检测并标记已更新歌单。
+
+ +
+
+
+
检查所有歌单更新
+
手动触发一次网络歌单更新检测,结果会在侧边栏中显示。
+
+ +
+
diff --git "a/t update\357\200\242" "b/t update\357\200\242" new file mode 100644 index 0000000..e206cc6 --- /dev/null +++ "b/t update\357\200\242" @@ -0,0 +1,225 @@ +diff --git a/.dockerignore b/.dockerignore +index f23b589..f863e81 100644 +--- a/.dockerignore ++++ b/.dockerignore +@@ -5,4 +5,4 @@ data + logs + lx-music-desktop-master + dist-electron +-build ++build +\ No newline at end of file +diff --git a/public/music/app.js b/public/music/app.js +index e742287..73374e0 100644 +--- a/public/music/app.js ++++ b/public/music/app.js +@@ -116,6 +116,7 @@ const DEFAULT_SETTINGS = { + playerBackground: 'blur', // 播放页背景: 'blur', 'solid', 'dark' + saveAccountSettingsToFile: true, // 同步账号设置到文件 (默认开启) + autoUpdateNetworkList: false, // 自动更新网络歌单 (默认关闭) ++ networkListAutoCheckInterval: '6h', // 网络歌单自动检测间隔 + preferServerCache: true, // 优先播放缓存歌曲 (默认开启) + remoteSyncUrl: '', // 远程同步地址 + remoteSyncCode: '', // 远程同步连接码 +@@ -145,6 +146,110 @@ try { + console.error('[Settings] 加载设置失败:', e); + } + window.settings = settings; // 显式挂载到 window ++window.networkListUpdateMap = new Set(); ++let networkListAutoCheckTimer = null; ++ ++function parseNetworkListAutoCheckInterval(value) { ++ if (value === undefined || value === null) return 0; ++ const raw = String(value).trim().toLowerCase(); ++ if (raw === '' || raw === '0' || raw === 'off' || raw === 'none' || raw === 'disable') return 0; ++ const matched = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/); ++ if (!matched) return null; ++ const count = parseFloat(matched[1]); ++ const unit = matched[2] || 'h'; ++ if (!Number.isFinite(count) || count < 0) return null; ++ switch (unit) { ++ case 'ms': return count; ++ case 's': return count * 1000; ++ case 'm': return count * 60 * 1000; ++ case 'h': return count * 60 * 60 * 1000; ++ case 'd': return count * 24 * 60 * 60 * 1000; ++ default: return null; ++ } ++} ++ ++function setupNetworkListAutoCheck() { ++ if (networkListAutoCheckTimer) { ++ clearInterval(networkListAutoCheckTimer); ++ networkListAutoCheckTimer = null; ++ } ++ if (!settings.autoUpdateNetworkList) { ++ return; ++ } ++ const intervalMs = parseNetworkListAutoCheckInterval(settings.networkListAutoCheckInterval); ++ if (intervalMs === null || intervalMs <= 0) { ++ return; ++ } ++ networkListAutoCheckTimer = setInterval(() => { ++ checkNetworkListUpdates().catch(err => console.error('[AutoCheck] 网络歌单检测失败:', err)); ++ }, intervalMs); ++ console.log('[AutoCheck] 已设置网络歌单自动检测间隔:', settings.networkListAutoCheckInterval, '(', intervalMs, 'ms )'); ++} ++ ++async function checkNetworkListUpdates(manual = false) { ++ if (!currentListData || !Array.isArray(currentListData.userList) || currentListData.userList.length === 0) { ++ if (manual && window.showToast) showToast('info', '当前没有可检查的网络歌单', 3000); ++ return; ++ } ++ ++ const targetLists = currentListData.userList.filter(l => l && l.sourceListId && l.source); ++ if (targetLists.length === 0) { ++ if (manual && window.showToast) showToast('info', '当前没有可检查的网络歌单', 3000); ++ return; ++ } ++ ++ const changedLists = []; ++ const failedLists = []; ++ ++ for (const list of targetLists) { ++ try { ++ const url = `${API_BASE}/songList/detail?source=${list.source}&id=${encodeURIComponent(list.sourceListId)}&page=1`; ++ const res = await fetch(url); ++ const data = await res.json(); ++ if (!data || !Array.isArray(data.list)) { ++ throw new Error('远端歌单数据不完整'); ++ } ++ ++ const remoteList = data.list.map(item => { ++ const formatted = formatSongToLxMusicStandard(item); ++ if (!formatted.source) formatted.source = list.source; ++ return formatted; ++ }); ++ ++ const localList = Array.isArray(list.list) ? list.list : []; ++ const sameLength = localList.length === remoteList.length; ++ const sameIds = sameLength && localList.every((item, index) => item && remoteList[index] && String(item.id || '') === String(remoteList[index].id || '') && String(item.source || '') === String(remoteList[index].source || '')); ++ if (!sameIds) { ++ window.networkListUpdateMap.add(list.id); ++ changedLists.push(list.name || list.id || list.sourceListId); ++ } else { ++ window.networkListUpdateMap.delete(list.id); ++ } ++ } catch (err) { ++ console.error('[CheckNetworkListUpdates] 检查失败:', list.name || list.id || list.sourceListId, err); ++ failedLists.push(list.name || list.id || list.sourceListId); ++ } ++ } ++ ++ if (typeof renderMyLists === 'function') { ++ renderMyLists(currentListData); ++ } ++ ++ if (manual) { ++ if (changedLists.length > 0) { ++ showSuccess(`检测到 ${changedLists.length} 个歌单已更新:${changedLists.join('、')}`); ++ } else { ++ showSuccess('所有网络歌单均为最新状态'); ++ } ++ if (failedLists.length > 0) { ++ showError(`部分歌单检测失败:${failedLists.join('、')}`); ++ } ++ } else if (changedLists.length > 0 && window.showToast) { ++ showToast('info', `检测到 ${changedLists.length} 个网络歌单有更新`, 5000); ++ } ++} ++ ++window.checkNetworkListUpdates = checkNetworkListUpdates; +  +  +  +@@ -4934,6 +5039,7 @@ function loadSettings() { +  + // 同步 UI 状态 + syncSettingsUI(); ++ setupNetworkListAutoCheck(); + } +  + // ========== 键盘快捷键逻辑 ========== +@@ -5092,6 +5198,13 @@ async function updateSetting(key, value) { + } + // 实时同步 UI 并应用效果 + syncSettingsUI(key, value); ++ if (key === 'networkListAutoCheckInterval' || key === 'autoUpdateNetworkList') { ++ const intervalMs = parseNetworkListAutoCheckInterval(settings.networkListAutoCheckInterval); ++ if (key === 'networkListAutoCheckInterval' && intervalMs === null) { ++ showError('无效的自动检测间隔,请使用 30m / 6h / 1d 等格式'); ++ } ++ setupNetworkListAutoCheck(); ++ } +  + // [New] Push to server if enabled + if (settings.saveAccountSettingsToFile) { +@@ -5256,6 +5369,7 @@ const SETTINGS_UI_MAP = { +  + // 系统 & 网络 (System & Network) + autoUpdateNetworkList: { id: 'setting-auto-update-list', type: 'checkbox' }, ++ networkListAutoCheckInterval: { id: 'setting-network-list-auto-check-interval', type: 'value' }, + saveAccountSettingsToFile: { id: 'setting-save-settings-to-file', type: 'checkbox' }, + enableLyricCache: { id: 'setting-enable-lyric-cache', type: 'checkbox' }, + enableSongUrlCache: { id: 'setting-enable-url-cache', type: 'checkbox' }, +@@ -8188,6 +8302,9 @@ function renderMyLists(data) { + const showExternalOps = listObj && listObj.sourceListId && listObj.source; + let opsHtml = ''; + if (showExternalOps) { ++ const updateBadge = window.networkListUpdateMap && window.networkListUpdateMap.has(id) ++ ? `!` ++ : ''; + opsHtml = ` +  ++ ${updateBadge} + `; + } +  +@@ -8666,6 +8784,11 @@ async function handleRefreshList(listId, event, silent = false) { + if (data.info.img || data.info.pic) list.Album = data.info.img || data.info.pic; + } +  ++ // 清除该列表的更新标记 ++ if (window.networkListUpdateMap) { ++ window.networkListUpdateMap.delete(listId); ++ } ++ + // 推送同步并重绘 UI + await pushDataChange(); + renderMyLists(currentListData); +diff --git a/public/music/index.html b/public/music/index.html +index c926125..573cc6f 100644 +--- a/public/music/index.html ++++ b/public/music/index.html +@@ -1689,6 +1689,26 @@ +  +
 +  ++
 ++
 ++
网络歌单自动检测间隔
 ++
输入值示例:30m / 6h / 1d,开启后自动检测并标记已更新歌单。
 ++
 ++  ++
 ++
 ++
 ++
检查所有歌单更新
 ++
手动触发一次网络歌单更新检测,结果会在侧边栏中显示。
 ++
 ++  ++
 ++ +  +
 +
 From a6fd72eac4cf6ff28d5c94f51bb26c3be256d833 Mon Sep 17 00:00:00 2001 From: lxserver-bot Date: Thu, 11 Jun 2026 16:59:01 +0800 Subject: [PATCH 03/26] chore: sync .dockerignore with upstream --- .dockerignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index f863e81..f23b589 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,4 @@ data logs lx-music-desktop-master dist-electron -build \ No newline at end of file +build From d39ed8ce3f65745f4f7b5b68f935e62058146641 Mon Sep 17 00:00:00 2001 From: lxserver-bot Date: Fri, 12 Jun 2026 15:25:11 +0800 Subject: [PATCH 04/26] fix: reuse auto source switching for downloads --- public/music/app.js | 25 +++++---- public/music/js/download_manager.js | 79 ++++++++++++++++------------- 2 files changed, 60 insertions(+), 44 deletions(-) diff --git a/public/music/app.js b/public/music/app.js index e742287..79f010e 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -2726,12 +2726,20 @@ async function resolveSongUrl(song, quality, isSilent = false, isRetry = false, } else if (step === 'switch_platform') { if (!isSilent) { console.log(`[AutoSource] 原始源解析失败,准备尝试全网匹配: ${song.name}`); - const matchedSong = await findOtherSourceMatch(song); - if (matchedSong) { + } + const matchedSong = await findOtherSourceMatch(song, isSilent); + if (matchedSong) { + if (!isSilent) { showInfo(`找到备选源,尝试从 ${getSourceName(matchedSong.source)} 播放...`); - const bestNextQuality = window.QualityManager.getBestQuality(matchedSong, settings.preferredQuality || '320k'); - return await fetchSongUrl(matchedSong, bestNextQuality, true, isSilent); } + const bestNextQuality = window.QualityManager.getBestQuality(matchedSong, settings.preferredQuality || '320k'); + const matchedResult = await fetchSongUrl(matchedSong, bestNextQuality, true, isSilent); + return { + ...matchedResult, + songInfo: matchedSong, + switchedSource: true, + originalSource: song.source + }; } } } @@ -2744,7 +2752,7 @@ async function resolveSongUrl(song, quality, isSilent = false, isRetry = false, * 跨平台寻找相同歌曲的匹配逻辑 * 基本规则:歌名+歌手+时长匹配 */ -async function findOtherSourceMatch(song) { +async function findOtherSourceMatch(song, isSilent = false) { if (!song.name || !song.singer) return null; try { @@ -2775,7 +2783,7 @@ async function findOtherSourceMatch(song) { // 4. 如果发现没有其他支持的平台可供切换 if (searchSources.length === 0) { console.log(`[AutoSource] 换源跳过:没有其他自定义源支持的平台。当前源: ${song.source}`); - showError('未找到自定义源下支持的平台下的对应歌曲'); + if (!isSilent) showError('未找到自定义源下支持的平台下的对应歌曲'); return null; } @@ -2783,7 +2791,7 @@ async function findOtherSourceMatch(song) { const headers = { 'Content-Type': 'application/json' }; Object.assign(headers, getUserAuthHeaders()); - showInfo('正在自动尝试换源匹配...'); + if (!isSilent) showInfo('正在自动尝试换源匹配...'); const searchPromises = searchSources.map(s => fetch(`${API_BASE}/search?name=${encodeURIComponent(query)}&source=${s}&page=1`, { headers }) @@ -3017,6 +3025,7 @@ async function fetchSongUrl(song, quality, isRetry = false, isSilent = false) { sourceType: 'normal', quality: result.type || quality, sourceName: result.sourceName, + songInfo: song, errorMsg: result.errorMsg }; } @@ -12308,5 +12317,3 @@ document.addEventListener('click', (e) => { document.addEventListener('DOMContentLoaded', () => { window.CustomSelectManager.initAll(); }); - - diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index 3e18c15..1a4052a 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -27,6 +27,17 @@ class DownloadManager { this.restoreTasks(); } + extractRawDownloadUrl(url) { + if (!url || !url.startsWith('/api/music/download')) return url; + try { + const proxyParams = new URLSearchParams(url.includes('?') ? url.split('?')[1] : ''); + const extracted = proxyParams.get('url'); + return extracted ? decodeURIComponent(extracted) : url; + } catch (e) { + return url; + } + } + // Update max concurrency limit dynamically updateMaxConcurrent(value) { console.log('[DownloadManager] Concurrency limit updated to:', value); @@ -391,13 +402,13 @@ class DownloadManager { const result = await resolveSongUrl(task.song, quality, true, true); if (!result || !result.url) throw new Error('解析失败'); - - let rawUrl = result.url; - if (rawUrl.startsWith('/api/music/download')) { - const proxyParams = new URLSearchParams(rawUrl.includes('?') ? rawUrl.split('?')[1] : ''); - const extracted = proxyParams.get('url'); - if (extracted) rawUrl = decodeURIComponent(extracted); + const resolvedSong = result.songInfo || task.song; + if (resolvedSong !== task.song) { + task.song = resolvedSong; + this.renderTask(task); } + + let rawUrl = this.extractRawDownloadUrl(result.url); if (!rawUrl.startsWith('http')) throw new Error('无法获取有效的外部下载地址'); // 2. Post to backend @@ -407,7 +418,7 @@ class DownloadManager { method: 'POST', headers, body: JSON.stringify({ - songInfo: task.song, + songInfo: resolvedSong, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, @@ -464,23 +475,18 @@ class DownloadManager { if (path.includes('.')) ext = path.split('.').pop(); } catch (e) { } } else { - // 2. 无缓存,向原站解析 URL - const headers = { 'Content-Type': 'application/json', ...(window.getUserAuthHeaders ? window.getUserAuthHeaders() : {}) }; + if (typeof resolveSongUrl !== 'function') throw new Error('resolveSongUrl missing'); + const resolveData = await resolveSongUrl(task.song, quality, true, true); + if (!resolveData || !resolveData.url) throw new Error('No download URL found'); - const resolveRes = await fetch('/api/music/url', { - method: 'POST', - headers, - body: JSON.stringify({ songInfo: task.song, quality }), - signal: task.controller.signal - }); - - if (!resolveRes.ok) throw new Error('Failed to resolve URL'); - const resolveData = await resolveRes.json(); - - if (!resolveData.url) throw new Error('No download URL found'); + const resolvedSong = resolveData.songInfo || task.song; + if (resolvedSong !== task.song) { + task.song = resolvedSong; + this.renderTask(task); + } finalUrl = resolveData.url; - ext = resolveData.type || 'mp3'; + ext = resolveData.quality || resolveData.type || 'mp3'; if (ext.startsWith('flac')) ext = 'flac'; // Handle flac24bit -> flac if (ext === '128k' || ext === '320k') ext = 'mp3'; } @@ -508,6 +514,7 @@ class DownloadManager { // [优化] 如果是本地缓存文件,不需要经过下载代理(已经有标签了) const isLocalCache = finalUrl.startsWith('/api/music/cache/file'); + finalUrl = this.extractRawDownloadUrl(finalUrl); if (shouldProxyDownload && !finalUrl.startsWith('/api/music/download') && !isLocalCache) { // Add metadata for tagging — 用 albumName 优先(playlist 字段),album 为兼容备选 @@ -711,13 +718,14 @@ class DownloadManager { const result = await resolveSongUrl(task.song, quality, true, true); if (!result || !result.url) throw new Error('获取播放地址失败'); - // [Fix] 还原代理 URL 为原始外部 URL - let rawUrl = result.url; - if (rawUrl.startsWith('/api/music/download')) { - const proxyParams = new URLSearchParams(rawUrl.includes('?') ? rawUrl.split('?')[1] : ''); - const extracted = proxyParams.get('url'); - if (extracted) rawUrl = decodeURIComponent(extracted); + const resolvedSong = result.songInfo || task.song; + if (resolvedSong !== task.song) { + task.song = resolvedSong; + this.renderTask(task); } + + // [Fix] 还原代理 URL 为原始外部 URL + let rawUrl = this.extractRawDownloadUrl(result.url); if (!rawUrl.startsWith('http')) throw new Error('无法获取有效的外部下载地址'); const headers = { 'Content-Type': 'application/json', ...(window.getUserAuthHeaders ? window.getUserAuthHeaders() : {}) }; @@ -725,7 +733,7 @@ class DownloadManager { const res = await fetch('/api/music/cache/download', { method: 'POST', headers, - body: JSON.stringify({ songInfo: task.song, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false }) + body: JSON.stringify({ songInfo: resolvedSong, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false }) }); if (!res.ok) throw new Error('服务器拒绝请求'); @@ -815,13 +823,14 @@ class DownloadManager { const result = await resolveSongUrl(t.song, quality, true, true); if (!result || !result.url) throw new Error('获取地址失败'); - // [Fix] 还原代理 URL 为原始外部 URL - let rawUrl = result.url; - if (rawUrl.startsWith('/api/music/download')) { - const proxyParams = new URLSearchParams(rawUrl.includes('?') ? rawUrl.split('?')[1] : ''); - const extracted = proxyParams.get('url'); - if (extracted) rawUrl = decodeURIComponent(extracted); + const resolvedSong = result.songInfo || t.song; + if (resolvedSong !== t.song) { + t.song = resolvedSong; + this.renderTask(t); } + + // [Fix] 还原代理 URL 为原始外部 URL + let rawUrl = this.extractRawDownloadUrl(result.url); if (!rawUrl.startsWith('http')) throw new Error('无法获取有效的外部下载地址'); const headers = { 'Content-Type': 'application/json', ...(window.getUserAuthHeaders ? window.getUserAuthHeaders() : {}) }; @@ -829,7 +838,7 @@ class DownloadManager { const res = await fetch('/api/music/cache/download', { method: 'POST', headers, - body: JSON.stringify({ songInfo: t.song, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false }) + body: JSON.stringify({ songInfo: resolvedSong, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false }) }); if (!res.ok) throw new Error('服务器拒绝缓存'); From 06213675696f0ede807f32771b7658f3e877721b Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Fri, 12 Jun 2026 21:06:25 +0800 Subject: [PATCH 05/26] fix: cache lyrics for playlist server downloads --- public/music/js/download_manager.js | 16 +++++++++----- public/music/js/single_song_ops.js | 31 ++++++++++++++++++++------ src/server/fileCache.ts | 34 +++++++++++++++++------------ src/server/server.ts | 6 ++--- 4 files changed, 57 insertions(+), 30 deletions(-) diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index 3e18c15..c15736b 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -161,15 +161,18 @@ class DownloadManager { if (!task || !task.isServer || task.status !== 'finished') return; // [优化] 如果已经有结果,或者重试超过 3 次,则不再请求 - if (task.hasLyric !== undefined || (task.lyricRetryCount || 0) >= 3) return; + if ((task.hasLyric === true || task.hasLyric === false) || (task.lyricRetryCount || 0) >= 3) return; try { // 记录重试次数 task.lyricRetryCount = (task.lyricRetryCount || 0) + 1; - const song = task.song; - const songId = song.songmid || song.songId || song.id; - const url = `/api/music/cache/lyric?source=${song.source}&songmid=${song.songmid || ''}&songId=${song.id || ''}`; + const song = task.song || {}; + const meta = song.meta || {}; + const source = song.source || meta.source || ''; + const songmid = song.songmid || song.songId || meta.songmid || meta.songId || song.id || ''; + const songId = song.id || song.songId || meta.songId || songmid; + const url = `/api/music/cache/lyric?source=${encodeURIComponent(source)}&songmid=${encodeURIComponent(songmid)}&songId=${encodeURIComponent(songId || '')}&name=${encodeURIComponent(song.name || meta.songName || '')}&singer=${encodeURIComponent(song.singer || meta.singerName || '')}`; // [修复] 补全认证请求头 const headers = { @@ -411,6 +414,7 @@ class DownloadManager { url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, + cacheLyric: window.settings?.enableServerLyricCache !== false, embedLyric: !!(window.settings?.embedLyricToFile ?? true) }) }); @@ -725,7 +729,7 @@ class DownloadManager { const res = await fetch('/api/music/cache/download', { method: 'POST', headers, - body: JSON.stringify({ songInfo: task.song, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false }) + body: JSON.stringify({ songInfo: task.song, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, cacheLyric: window.settings?.enableServerLyricCache !== false, embedLyric: !!(window.settings?.embedLyricToFile ?? true) }) }); if (!res.ok) throw new Error('服务器拒绝请求'); @@ -829,7 +833,7 @@ class DownloadManager { const res = await fetch('/api/music/cache/download', { method: 'POST', headers, - body: JSON.stringify({ songInfo: t.song, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false }) + body: JSON.stringify({ songInfo: t.song, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, cacheLyric: window.settings?.enableServerLyricCache !== false, embedLyric: !!(window.settings?.embedLyricToFile ?? true) }) }); if (!res.ok) throw new Error('服务器拒绝缓存'); diff --git a/public/music/js/single_song_ops.js b/public/music/js/single_song_ops.js index b57208c..9d01bde 100644 --- a/public/music/js/single_song_ops.js +++ b/public/music/js/single_song_ops.js @@ -103,12 +103,20 @@ async function requestServerLyricCache(song, quality = null, force = false) { console.log(`[Lyric] 尝试同步下载歌词缓存: ${song.name} (${quality || 'auto'})`); try { - const source = song.source; - const songmid = song.songmid; - const name = encodeURIComponent(song.name); - const singer = encodeURIComponent(song.singer); - const hash = song.hash || ''; - const interval = song.interval || ''; + const meta = song.meta || {}; + const source = song.source || meta.source || ''; + const songmid = song.songmid || song.songId || meta.songmid || meta.songId || song.id || ''; + const nameValue = song.name || meta.songName || ''; + const singerValue = song.singer || meta.singerName || ''; + const name = encodeURIComponent(nameValue); + const singer = encodeURIComponent(singerValue); + const hash = song.hash || meta.hash || ''; + const interval = song.interval || meta.interval || ''; + + if (!source || !songmid) { + console.warn('[Lyric] 歌曲缺少必要字段,跳过歌词缓存同步:', song); + return; + } // 1. 先尝试获取歌词数据 const lyricUrl = `/api/music/lyric?source=${source}&songmid=${songmid}&name=${name}&singer=${singer}&hash=${hash}&interval=${interval}`; @@ -126,7 +134,16 @@ async function requestServerLyricCache(song, quality = null, force = false) { }; // 构建包含音质信息的 songInfo - const songInfoForCache = { ...song }; + const songInfoForCache = { + ...song, + source, + songmid, + songId: song.songId || meta.songId || songmid, + name: nameValue, + singer: singerValue, + hash, + interval + }; if (quality) songInfoForCache.quality = quality; const enableOnlyDownloadMode = window.settings?.enableOnlyDownloadMode || false; diff --git a/src/server/fileCache.ts b/src/server/fileCache.ts index 9a62cbb..151126c 100644 --- a/src/server/fileCache.ts +++ b/src/server/fileCache.ts @@ -1239,7 +1239,7 @@ export const saveLyricCache = (songInfo: any, lyricsObj: any, username?: string, } } -export const downloadAndCache = async (songInfo: any, url: string, quality?: string, username?: string, signal?: AbortSignal, isOnlyDownload?: boolean, shouldEmbedLyric: boolean = true) => { +export const downloadAndCache = async (songInfo: any, url: string, quality?: string, username?: string, signal?: AbortSignal, isOnlyDownload?: boolean, shouldCacheLyric: boolean = true, shouldEmbedLyric: boolean = true) => { const dir = ensureDir(username, isOnlyDownload) const baseName = getFileName(songInfo, quality, isOnlyDownload, username) const tempPath = path.join(dir, baseName + '.tmp') @@ -1368,22 +1368,29 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str tagger.dispose() } catch (e) { } - // [新增] 嵌入歌词 USLT 标签(根据 shouldEmbedLyric 判断) - if (shouldEmbedLyric && _lyricFetcher) { + if ((shouldCacheLyric || shouldEmbedLyric) && _lyricFetcher) { try { const lyricText = await _lyricFetcher({ ...songInfo, quality }) if (lyricText) { - const tagger2 = new MusicTagger() - tagger2.loadPath(finalPath) - tagger2.lyrics = lyricText - tagger2.save() - tagger2.dispose() - console.log(`[FileCache] USLT lyric embedded for: ${metadata.name}`) - // [新增] 同步更新索引中的 hasEmbedLyric 状态 - const finalItem = indexManager.get(normalizedUsername, id, folderType, quality || 'unknown') - if (finalItem) { (finalItem as any).hasEmbedLyric = true } + const lyricsObj = parseLyrics(lyricText) + if (shouldCacheLyric) { + saveLyricCache({ ...songInfo, quality }, lyricsObj, username, isOnlyDownload) + } + if (shouldEmbedLyric) { + const tagger2 = new MusicTagger() + tagger2.loadPath(finalPath) + tagger2.lyrics = lyricText + tagger2.save() + tagger2.dispose() + console.log(`[FileCache] USLT lyric embedded for: ${metadata.name}`) + const finalItem = indexManager.get(normalizedUsername, id, folderType, quality || 'unknown') + if (finalItem) { + ;(finalItem as any).hasEmbedLyric = true + indexManager.save(normalizedUsername, folderType) + } + } } - } catch (e) { /* 歌词写入失败不影响缓存结果 */ } + } catch (e) { /* Lyric cache/embed failure must not fail the audio cache. */ } } cacheProgress.set(songKey, { progress: 100, status: 'finished' }) @@ -1894,4 +1901,3 @@ export const categorizeFiles = async (filenames: string[], targetSubPath: string indexManager.save(normalizedUsername, folder) return { successCount, failCount } } - diff --git a/src/server/server.ts b/src/server/server.ts index 8b7575a..d09f264 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -2329,7 +2329,7 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro if (pathname === '/api/music/cache/download' && req.method === 'POST') { void readBody(req).then(body => { try { - const { songInfo, url, quality, enableOnlyDownloadMode, embedLyric } = JSON.parse(body) + const { songInfo, url, quality, enableOnlyDownloadMode, cacheLyric, embedLyric } = JSON.parse(body) if (!songInfo || !url) { res.writeHead(400) res.end('Missing params') @@ -2362,7 +2362,7 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro } userTasks.push({ songKey, controller }) - void fileCache.downloadAndCache(songInfo, url, quality, username, controller.signal, !!enableOnlyDownloadMode, embedLyric !== false) + void fileCache.downloadAndCache(songInfo, url, quality, username, controller.signal, !!enableOnlyDownloadMode, cacheLyric !== false, embedLyric !== false) .then(() => console.log(`[Cache] Downloaded ${songInfo.name} for ${username || '_open'}`)) .catch((err: any) => { if (err.message === 'Aborted') { @@ -5675,4 +5675,4 @@ export const removeDevice = async (userName: string, clientId: string) => { } const userSpace = getUserSpace(userName) await userSpace.removeDevice(clientId) -} \ No newline at end of file +} From 0a065ca9e8635b64057b21bcecf53bca573df360 Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Sat, 13 Jun 2026 21:06:33 +0800 Subject: [PATCH 06/26] fix: sync naming pattern for download mode --- public/music/app.js | 8 ++++---- src/server/server.ts | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/public/music/app.js b/public/music/app.js index e742287..c24a306 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -151,8 +151,8 @@ window.settings = settings; // 显式挂载到 window // Initial Sync for Server Cache Config setTimeout(() => { if (settings.serverCacheLocation && window.updateServerCacheConfig) { - console.log('[ServerCache] Syncing config:', settings.serverCacheLocation); - window.updateServerCacheConfig(settings.serverCacheLocation); + console.log('[ServerCache] Syncing config:', settings.serverCacheLocation, settings.serverCacheNamingPattern); + window.updateServerCacheConfig(settings.serverCacheLocation, settings.serverCacheNamingPattern); } }, 2000); @@ -5061,7 +5061,7 @@ document.addEventListener('keyup', (e) => { }); async function updateSetting(key, value) { - const restrictedKeys = ['enableServerCache', 'enableServerLyricCache', 'serverCacheLocation', 'enableOnlyDownloadMode']; + const restrictedKeys = ['enableServerCache', 'enableServerLyricCache', 'serverCacheLocation', 'serverCacheNamingPattern', 'enableOnlyDownloadMode']; const isPublic = !currentListData?.username || currentListData?.username === 'default'; const enablePublicRestriction = window.lx_config?.['user.enablePublicRestriction']; const enableLoginCacheRestriction = window.lx_config?.['user.enableLoginCacheRestriction']; @@ -5303,7 +5303,7 @@ function syncSettingsUI(key = null, value = null) { const enablePublicRestriction = window.lx_config?.['user.enablePublicRestriction']; const enableLoginCacheRestriction = window.lx_config?.['user.enableLoginCacheRestriction']; const isAdmin = !!localStorage.getItem('lx_admin_password'); - const restrictedKeys = ['enableServerCache', 'enableServerLyricCache', 'serverCacheLocation', 'enableOnlyDownloadMode']; + const restrictedKeys = ['enableServerCache', 'enableServerLyricCache', 'serverCacheLocation', 'serverCacheNamingPattern', 'enableOnlyDownloadMode']; const updateItem = (itemKey, itemValue, isSingle) => { const config = SETTINGS_UI_MAP[itemKey]; diff --git a/src/server/server.ts b/src/server/server.ts index 8b7575a..1615b6c 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1802,7 +1802,7 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro // [核心逻辑] 如果是受限的公开用户,仅允许保存特定的 3 项设置 if (resolvedUsername === '_open' && global.lx.config['user.enablePublicRestriction']) { const restrictedSettings: any = {} - const allowedKeys = ['enableServerCache', 'enableServerLyricCache', 'serverCacheLocation'] + const allowedKeys = ['enableServerCache', 'enableServerLyricCache', 'serverCacheLocation', 'serverCacheNamingPattern'] allowedKeys.forEach(key => { if (settings[key] !== undefined) restrictedSettings[key] = settings[key] }) @@ -5629,6 +5629,10 @@ export const startServer = async (port: number, ip: string) => { fileCache.setCacheLocation(savedSettings.serverCacheLocation) console.log(`[Server] Restored fileCache location from settings: ${savedSettings.serverCacheLocation}`) } + if (savedSettings.serverCacheNamingPattern) { + fileCache.setNamingPattern(savedSettings.serverCacheNamingPattern) + console.log(`[Server] Restored cache naming pattern from settings: ${savedSettings.serverCacheNamingPattern}`) + } } } catch (err: any) { console.warn('[Server] Failed to restore fileCache location:', err.message) From a32632880b846b00c788f267ab2cd132b9a2ce76 Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Sun, 14 Jun 2026 12:43:08 +0800 Subject: [PATCH 07/26] fix: persist valid download concurrency setting --- public/music/app.js | 7 ++++--- public/music/index.html | 8 ++++---- public/music/js/download_manager.js | 12 +++++++++--- src/server/server.ts | 4 ++-- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/public/music/app.js b/public/music/app.js index e742287..44c6072 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -74,7 +74,7 @@ const DEFAULT_SETTINGS = { enableCustomProxy: false, // 是否启用自定义代理 customProxyUrl: '', // 自定义代理URL模板,使用 {url} 作为原始URL占位符 enableOnlyDownloadMode: false, // 仅下载模式 - downloadConcurrency: 3, // 缓存并发量 (1-6) + downloadConcurrency: 3, // 缓存并发量 (1-5) hotSearchLimit: 20, // 热搜显示数量 lyricFontSize: 1.25, // 歌词字体大小 (rem) lyricFontFamily: '', // 词字体 @@ -5156,9 +5156,10 @@ const SETTINGS_UI_MAP = { downloadConcurrency: { id: 'setting-download-concurrency', type: 'value', + normalize: (v) => Math.min(5, Math.max(1, parseInt(v, 10) || DEFAULT_SETTINGS.downloadConcurrency)), action: (v) => { if (window.SystemDownloadManager) { - window.SystemDownloadManager.updateMaxConcurrent(parseInt(v)); + window.SystemDownloadManager.updateMaxConcurrent(v); } } }, @@ -5309,6 +5310,7 @@ function syncSettingsUI(key = null, value = null) { const config = SETTINGS_UI_MAP[itemKey]; if (!config) return; + if (config.normalize) itemValue = config.normalize(itemValue); const el = document.getElementById(config.id); if (el) { if (config.type === 'checkbox') el.checked = !!itemValue; @@ -12309,4 +12311,3 @@ document.addEventListener('DOMContentLoaded', () => { window.CustomSelectManager.initAll(); }); - diff --git a/public/music/index.html b/public/music/index.html index c926125..b8f91f7 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -1562,13 +1562,13 @@

网络设置

- - + -
@@ -4745,4 +4745,4 @@

项目使用声明< - \ No newline at end of file + diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index 3e18c15..31ffa8c 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -6,7 +6,7 @@ class DownloadManager { constructor() { this.tasks = []; // Queue of tasks - this.maxConcurrent = window.settings?.downloadConcurrency || 3; + this.maxConcurrent = this.normalizeConcurrency(window.settings?.downloadConcurrency); this.activeCount = 0; // Currently active (local downloading + triggered server tasks) // UI Elements @@ -29,11 +29,17 @@ class DownloadManager { // Update max concurrency limit dynamically updateMaxConcurrent(value) { - console.log('[DownloadManager] Concurrency limit updated to:', value); - this.maxConcurrent = value; + this.maxConcurrent = this.normalizeConcurrency(value); + console.log('[DownloadManager] Concurrency limit updated to:', this.maxConcurrent); this.processQueue(); } + normalizeConcurrency(value) { + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed)) return 3; + return Math.min(5, Math.max(1, parsed)); + } + async pollServerProgress() { // [Move to top] 对已完成但还未检测过歌词的云端任务,执行检测 // 这样即使当前没有正在下载的任务,刷新页面后也能触发一次歌词状态刷新 diff --git a/src/server/server.ts b/src/server/server.ts index 8b7575a..5219b91 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1802,7 +1802,7 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro // [核心逻辑] 如果是受限的公开用户,仅允许保存特定的 3 项设置 if (resolvedUsername === '_open' && global.lx.config['user.enablePublicRestriction']) { const restrictedSettings: any = {} - const allowedKeys = ['enableServerCache', 'enableServerLyricCache', 'serverCacheLocation'] + const allowedKeys = ['enableServerCache', 'enableServerLyricCache', 'serverCacheLocation', 'downloadConcurrency'] allowedKeys.forEach(key => { if (settings[key] !== undefined) restrictedSettings[key] = settings[key] }) @@ -5675,4 +5675,4 @@ export const removeDevice = async (userName: string, clientId: string) => { } const userSpace = getUserSpace(userName) await userSpace.removeDevice(clientId) -} \ No newline at end of file +} From c79bab79b25aaf884dac882c52b2279e37f51bc2 Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Sun, 14 Jun 2026 19:15:02 +0800 Subject: [PATCH 08/26] fix: apply PR review suggestions --- public/music/app.js | 17 ++- public/music/js/download_manager.js | 2 +- "t update\357\200\242" | 225 ---------------------------- 3 files changed, 11 insertions(+), 233 deletions(-) delete mode 100644 "t update\357\200\242" diff --git a/public/music/app.js b/public/music/app.js index 0849c44..3e7c6c0 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -150,6 +150,7 @@ window.networkListUpdateMap = new Set(); let networkListAutoCheckTimer = null; function parseNetworkListAutoCheckInterval(value) { + const minIntervalMs = 30 * 1000; if (value === undefined || value === null) return 0; const raw = String(value).trim().toLowerCase(); if (raw === '' || raw === '0' || raw === 'off' || raw === 'none' || raw === 'disable') return 0; @@ -158,14 +159,16 @@ function parseNetworkListAutoCheckInterval(value) { const count = parseFloat(matched[1]); const unit = matched[2] || 'h'; if (!Number.isFinite(count) || count < 0) return null; + let intervalMs = null; switch (unit) { - case 'ms': return count; - case 's': return count * 1000; - case 'm': return count * 60 * 1000; - case 'h': return count * 60 * 60 * 1000; - case 'd': return count * 24 * 60 * 60 * 1000; + case 'ms': intervalMs = count; break; + case 's': intervalMs = count * 1000; break; + case 'm': intervalMs = count * 60 * 1000; break; + case 'h': intervalMs = count * 60 * 60 * 1000; break; + case 'd': intervalMs = count * 24 * 60 * 60 * 1000; break; default: return null; } + return Math.max(intervalMs, minIntervalMs); } function setupNetworkListAutoCheck() { @@ -203,7 +206,7 @@ async function checkNetworkListUpdates(manual = false) { for (const list of targetLists) { try { - const url = `${API_BASE}/songList/detail?source=${list.source}&id=${encodeURIComponent(list.sourceListId)}&page=1`; + const url = `${API_BASE}/songList/detail?source=${encodeURIComponent(list.source)}&id=${encodeURIComponent(list.sourceListId)}&page=1`; const res = await fetch(url); const data = await res.json(); if (!data || !Array.isArray(data.list)) { @@ -8779,7 +8782,7 @@ async function handleRefreshList(listId, event, silent = false) { if (window.showToast) window.showToast('info', '正在同步最新歌单内容...'); try { - const url = `${API_BASE}/songList/detail?source=${list.source}&id=${encodeURIComponent(list.sourceListId)}&page=1`; + const url = `${API_BASE}/songList/detail?source=${encodeURIComponent(list.source)}&id=${encodeURIComponent(list.sourceListId)}&page=1`; const res = await fetch(url); const data = await res.json(); diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index 1a4ee36..bae48c6 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -32,7 +32,7 @@ class DownloadManager { try { const proxyParams = new URLSearchParams(url.includes('?') ? url.split('?')[1] : ''); const extracted = proxyParams.get('url'); - return extracted ? decodeURIComponent(extracted) : url; + return extracted || url; } catch (e) { return url; } diff --git "a/t update\357\200\242" "b/t update\357\200\242" deleted file mode 100644 index e206cc6..0000000 --- "a/t update\357\200\242" +++ /dev/null @@ -1,225 +0,0 @@ -diff --git a/.dockerignore b/.dockerignore -index f23b589..f863e81 100644 ---- a/.dockerignore -+++ b/.dockerignore -@@ -5,4 +5,4 @@ data - logs - lx-music-desktop-master - dist-electron --build -+build -\ No newline at end of file -diff --git a/public/music/app.js b/public/music/app.js -index e742287..73374e0 100644 ---- a/public/music/app.js -+++ b/public/music/app.js -@@ -116,6 +116,7 @@ const DEFAULT_SETTINGS = { - playerBackground: 'blur', // 播放页背景: 'blur', 'solid', 'dark' - saveAccountSettingsToFile: true, // 同步账号设置到文件 (默认开启) - autoUpdateNetworkList: false, // 自动更新网络歌单 (默认关闭) -+ networkListAutoCheckInterval: '6h', // 网络歌单自动检测间隔 - preferServerCache: true, // 优先播放缓存歌曲 (默认开启) - remoteSyncUrl: '', // 远程同步地址 - remoteSyncCode: '', // 远程同步连接码 -@@ -145,6 +146,110 @@ try { - console.error('[Settings] 加载设置失败:', e); - } - window.settings = settings; // 显式挂载到 window -+window.networkListUpdateMap = new Set(); -+let networkListAutoCheckTimer = null; -+ -+function parseNetworkListAutoCheckInterval(value) { -+ if (value === undefined || value === null) return 0; -+ const raw = String(value).trim().toLowerCase(); -+ if (raw === '' || raw === '0' || raw === 'off' || raw === 'none' || raw === 'disable') return 0; -+ const matched = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/); -+ if (!matched) return null; -+ const count = parseFloat(matched[1]); -+ const unit = matched[2] || 'h'; -+ if (!Number.isFinite(count) || count < 0) return null; -+ switch (unit) { -+ case 'ms': return count; -+ case 's': return count * 1000; -+ case 'm': return count * 60 * 1000; -+ case 'h': return count * 60 * 60 * 1000; -+ case 'd': return count * 24 * 60 * 60 * 1000; -+ default: return null; -+ } -+} -+ -+function setupNetworkListAutoCheck() { -+ if (networkListAutoCheckTimer) { -+ clearInterval(networkListAutoCheckTimer); -+ networkListAutoCheckTimer = null; -+ } -+ if (!settings.autoUpdateNetworkList) { -+ return; -+ } -+ const intervalMs = parseNetworkListAutoCheckInterval(settings.networkListAutoCheckInterval); -+ if (intervalMs === null || intervalMs <= 0) { -+ return; -+ } -+ networkListAutoCheckTimer = setInterval(() => { -+ checkNetworkListUpdates().catch(err => console.error('[AutoCheck] 网络歌单检测失败:', err)); -+ }, intervalMs); -+ console.log('[AutoCheck] 已设置网络歌单自动检测间隔:', settings.networkListAutoCheckInterval, '(', intervalMs, 'ms )'); -+} -+ -+async function checkNetworkListUpdates(manual = false) { -+ if (!currentListData || !Array.isArray(currentListData.userList) || currentListData.userList.length === 0) { -+ if (manual && window.showToast) showToast('info', '当前没有可检查的网络歌单', 3000); -+ return; -+ } -+ -+ const targetLists = currentListData.userList.filter(l => l && l.sourceListId && l.source); -+ if (targetLists.length === 0) { -+ if (manual && window.showToast) showToast('info', '当前没有可检查的网络歌单', 3000); -+ return; -+ } -+ -+ const changedLists = []; -+ const failedLists = []; -+ -+ for (const list of targetLists) { -+ try { -+ const url = `${API_BASE}/songList/detail?source=${list.source}&id=${encodeURIComponent(list.sourceListId)}&page=1`; -+ const res = await fetch(url); -+ const data = await res.json(); -+ if (!data || !Array.isArray(data.list)) { -+ throw new Error('远端歌单数据不完整'); -+ } -+ -+ const remoteList = data.list.map(item => { -+ const formatted = formatSongToLxMusicStandard(item); -+ if (!formatted.source) formatted.source = list.source; -+ return formatted; -+ }); -+ -+ const localList = Array.isArray(list.list) ? list.list : []; -+ const sameLength = localList.length === remoteList.length; -+ const sameIds = sameLength && localList.every((item, index) => item && remoteList[index] && String(item.id || '') === String(remoteList[index].id || '') && String(item.source || '') === String(remoteList[index].source || '')); -+ if (!sameIds) { -+ window.networkListUpdateMap.add(list.id); -+ changedLists.push(list.name || list.id || list.sourceListId); -+ } else { -+ window.networkListUpdateMap.delete(list.id); -+ } -+ } catch (err) { -+ console.error('[CheckNetworkListUpdates] 检查失败:', list.name || list.id || list.sourceListId, err); -+ failedLists.push(list.name || list.id || list.sourceListId); -+ } -+ } -+ -+ if (typeof renderMyLists === 'function') { -+ renderMyLists(currentListData); -+ } -+ -+ if (manual) { -+ if (changedLists.length > 0) { -+ showSuccess(`检测到 ${changedLists.length} 个歌单已更新:${changedLists.join('、')}`); -+ } else { -+ showSuccess('所有网络歌单均为最新状态'); -+ } -+ if (failedLists.length > 0) { -+ showError(`部分歌单检测失败:${failedLists.join('、')}`); -+ } -+ } else if (changedLists.length > 0 && window.showToast) { -+ showToast('info', `检测到 ${changedLists.length} 个网络歌单有更新`, 5000); -+ } -+} -+ -+window.checkNetworkListUpdates = checkNetworkListUpdates; -  -  -  -@@ -4934,6 +5039,7 @@ function loadSettings() { -  - // 同步 UI 状态 - syncSettingsUI(); -+ setupNetworkListAutoCheck(); - } -  - // ========== 键盘快捷键逻辑 ========== -@@ -5092,6 +5198,13 @@ async function updateSetting(key, value) { - } - // 实时同步 UI 并应用效果 - syncSettingsUI(key, value); -+ if (key === 'networkListAutoCheckInterval' || key === 'autoUpdateNetworkList') { -+ const intervalMs = parseNetworkListAutoCheckInterval(settings.networkListAutoCheckInterval); -+ if (key === 'networkListAutoCheckInterval' && intervalMs === null) { -+ showError('无效的自动检测间隔,请使用 30m / 6h / 1d 等格式'); -+ } -+ setupNetworkListAutoCheck(); -+ } -  - // [New] Push to server if enabled - if (settings.saveAccountSettingsToFile) { -@@ -5256,6 +5369,7 @@ const SETTINGS_UI_MAP = { -  - // 系统 & 网络 (System & Network) - autoUpdateNetworkList: { id: 'setting-auto-update-list', type: 'checkbox' }, -+ networkListAutoCheckInterval: { id: 'setting-network-list-auto-check-interval', type: 'value' }, - saveAccountSettingsToFile: { id: 'setting-save-settings-to-file', type: 'checkbox' }, - enableLyricCache: { id: 'setting-enable-lyric-cache', type: 'checkbox' }, - enableSongUrlCache: { id: 'setting-enable-url-cache', type: 'checkbox' }, -@@ -8188,6 +8302,9 @@ function renderMyLists(data) { - const showExternalOps = listObj && listObj.sourceListId && listObj.source; - let opsHtml = ''; - if (showExternalOps) { -+ const updateBadge = window.networkListUpdateMap && window.networkListUpdateMap.has(id) -+ ? `!` -+ : ''; - opsHtml = ` -  -+ ${updateBadge} - `; - } -  -@@ -8666,6 +8784,11 @@ async function handleRefreshList(listId, event, silent = false) { - if (data.info.img || data.info.pic) list.Album = data.info.img || data.info.pic; - } -  -+ // 清除该列表的更新标记 -+ if (window.networkListUpdateMap) { -+ window.networkListUpdateMap.delete(listId); -+ } -+ - // 推送同步并重绘 UI - await pushDataChange(); - renderMyLists(currentListData); -diff --git a/public/music/index.html b/public/music/index.html -index c926125..573cc6f 100644 ---- a/public/music/index.html -+++ b/public/music/index.html -@@ -1689,6 +1689,26 @@ -  -  -  -+
 -+
 -+
网络歌单自动检测间隔
 -+
输入值示例:30m / 6h / 1d,开启后自动检测并标记已更新歌单。
 -+
 -+  -+
 -+
 -+
 -+
检查所有歌单更新
 -+
手动触发一次网络歌单更新检测,结果会在侧边栏中显示。
 -+
 -+  -+
 -+ -  -
 -
 From 4e1ec92b4d58863920d23629d04a5a4d9d9ea446 Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Sun, 14 Jun 2026 19:16:19 +0800 Subject: [PATCH 09/26] fix: reset batch mode after batch actions --- public/music/app.js | 2 -- public/music/js/batch_pagination.js | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/public/music/app.js b/public/music/app.js index 3e7c6c0..96c728a 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -10062,8 +10062,6 @@ async function handleTogglePlaylist(listId, btnElement) { // Cleanup selection if (typeof deselectAll === 'function') deselectAll(); - if (typeof toggleBatchMode === 'function') toggleBatchMode(); - if (typeof toggleLbBatchMode === 'function') toggleLbBatchMode(); } catch (e) { console.error('[BatchCollect] Sync failed, reverting or refreshing:', e); diff --git a/public/music/js/batch_pagination.js b/public/music/js/batch_pagination.js index 0c43cf4..4e780c4 100644 --- a/public/music/js/batch_pagination.js +++ b/public/music/js/batch_pagination.js @@ -93,6 +93,7 @@ function selectAllVisible() { } function deselectAll() { + window.batchMode = false; window.selectedItems.clear(); window.selectedSongObjects.clear(); @@ -250,9 +251,7 @@ async function batchDeleteFromList() { } // Clear selection and exit batch mode - window.selectedItems.clear(); - window.batchMode = false; - toggleBatchMode(); // Update UI + deselectAll(); } // Helper: Get current active list ID From abebda1ca3c21a217e742bb36e23cc9cf635693d Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Sun, 14 Jun 2026 19:27:20 +0800 Subject: [PATCH 10/26] fix: handle encoded proxied download URLs --- public/music/js/download_manager.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index bae48c6..132462c 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -32,7 +32,10 @@ class DownloadManager { try { const proxyParams = new URLSearchParams(url.includes('?') ? url.split('?')[1] : ''); const extracted = proxyParams.get('url'); - return extracted || url; + if (!extracted) return url; + if (extracted.startsWith('http')) return extracted; + const decoded = decodeURIComponent(extracted); + return decoded.startsWith('http') ? decoded : extracted; } catch (e) { return url; } From ddf2104d5bbc575bc49bfac23ad149dd7789c8d2 Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Sun, 14 Jun 2026 19:33:11 +0800 Subject: [PATCH 11/26] fix: resolve external URLs for server downloads --- public/music/js/download_manager.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index 132462c..58cdee0 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -411,7 +411,7 @@ class DownloadManager { const quality = task.quality || (window.QualityManager ? window.QualityManager.getBestQuality(task.song, window.settings?.preferredQuality || '320k') : '320k'); task.quality = quality; - const result = await resolveSongUrl(task.song, quality, true, true); + const result = await resolveSongUrl(task.song, quality, true, 'local_retry'); if (!result || !result.url) throw new Error('解析失败'); const resolvedSong = result.songInfo || task.song; @@ -728,7 +728,7 @@ class DownloadManager { if (window.QualityManager) { quality = window.QualityManager.getBestQuality(task.song, quality); } - const result = await resolveSongUrl(task.song, quality, true, true); + const result = await resolveSongUrl(task.song, quality, true, 'local_retry'); if (!result || !result.url) throw new Error('获取播放地址失败'); const resolvedSong = result.songInfo || task.song; @@ -833,7 +833,7 @@ class DownloadManager { // getBestQuality 能处理原始 code 和 preferred 偏好,传入 t.quality 作为偏好,让其降级匹配 quality = window.QualityManager.getBestQuality(t.song, quality); } - const result = await resolveSongUrl(t.song, quality, true, true); + const result = await resolveSongUrl(t.song, quality, true, 'local_retry'); if (!result || !result.url) throw new Error('获取地址失败'); const resolvedSong = result.songInfo || t.song; From b336bdc9a52fc113fc076c3faef31d292587f65f Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Sun, 14 Jun 2026 19:52:48 +0800 Subject: [PATCH 12/26] fix: keep external url resolution for server downloads --- public/music/app.js | 8 +++++--- public/music/js/download_manager.js | 11 +++++++++++ src/server/fileCache.ts | 17 +++++++++++------ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/public/music/app.js b/public/music/app.js index 96c728a..c333f80 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -2820,6 +2820,8 @@ async function resolveSongUrl(song, quality, isSilent = false, isRetry = false, } } + const fallbackRetryMode = isRetry === 'local_retry' ? 'local_retry' : true; + for (const step of steps) { if (step === 'degrade') { const nextQuality = isPlatformNotSupported ? null : window.QualityManager.getNextLowerQuality(quality, song); @@ -2829,7 +2831,7 @@ async function resolveSongUrl(song, quality, isSilent = false, isRetry = false, const toName = window.QualityManager.getQualityDisplayName(nextQuality); showInfo(`从 ${fromName} 降级到 ${toName} 播放...`); } - return await resolveSongUrl(song, nextQuality, isSilent, true, false); + return await resolveSongUrl(song, nextQuality, isSilent, fallbackRetryMode, false); } } else if (step === 'switch_platform') { if (!isSilent) { @@ -2841,7 +2843,7 @@ async function resolveSongUrl(song, quality, isSilent = false, isRetry = false, showInfo(`找到备选源,尝试从 ${getSourceName(matchedSong.source)} 播放...`); } const bestNextQuality = window.QualityManager.getBestQuality(matchedSong, settings.preferredQuality || '320k'); - const matchedResult = await fetchSongUrl(matchedSong, bestNextQuality, true, isSilent); + const matchedResult = await fetchSongUrl(matchedSong, bestNextQuality, fallbackRetryMode, isSilent); return { ...matchedResult, songInfo: matchedSong, @@ -3055,7 +3057,7 @@ async function fetchSongUrl(song, quality, isRetry = false, isSilent = false) { } } - const allowLinkCache = (!isRetry || isRetry === 'local_retry') && settings.enableSongUrlCache !== false; + const allowLinkCache = !isRetry && settings.enableSongUrlCache !== false; if (allowLinkCache) { let cachedUrl = localStorage.getItem(cacheKey); if (cachedUrl) { diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index 58cdee0..4339da6 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -118,6 +118,17 @@ class DownloadManager { task.lastPolledBytes = progressInfo.received || 0; task.lastPolledTime = Date.now(); + if (progressInfo.status === 'error') { + task.status = 'error'; + task.progress = progressInfo.progress || 0; + task.errorMsg = progressInfo.errorMsg || '服务器下载失败'; + task.speed = 0; + this.renderTask(task); + this.saveTasks(); + this.processQueue(); + return; + } + task.status = (progressInfo.status === 'finished' || progressInfo.status === 'exists') ? progressInfo.status : 'downloading'; task.progress = progressInfo.progress || 0; task.downloadedBytes = progressInfo.received || 0; diff --git a/src/server/fileCache.ts b/src/server/fileCache.ts index 151126c..0746e4b 100644 --- a/src/server/fileCache.ts +++ b/src/server/fileCache.ts @@ -36,7 +36,7 @@ let currentCacheLocation = CACHE_ROOTS.ROOT // Helper to get actual directory path // [Unified Enhancement] Cache Progress Tracker -export const cacheProgress: Map = new Map() +export const cacheProgress: Map = new Map() // [New] Active Cache Tasks Tracker: username -> [ { songKey, controller } ] export const activeTasks: Map> = new Map() @@ -1262,6 +1262,12 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str let req: http.ClientRequest let settled = false + const fail = (err: Error) => { + const message = err.message || 'Download failed' + cacheProgress.set(songKey, { progress: 0, status: 'error', errorMsg: message }) + settle(() => reject(err)) + } + const settle = (fn: () => void) => { if (settled) return settled = true @@ -1281,8 +1287,7 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str req = protocol.get(url, (res) => { if (res.statusCode !== 200) { fs.unlink(tempPath, () => { }) - cacheProgress.set(songKey, { progress: 0, status: 'error' }) - settle(() => reject(new Error(`Status: ${res.statusCode}`))) + fail(new Error(`Status: ${res.statusCode}`)) return } @@ -1323,7 +1328,7 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str fs.rename(tempPath, finalPath, async (err) => { if (err) { fs.unlink(tempPath, () => { }) - settle(() => reject(err)) + fail(err) return } @@ -1398,9 +1403,9 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str settle(() => { resolve(); void checkAndCleanupCache(username) }) }) }) - fileStream.on('error', (err) => { fs.unlink(tempPath, () => { }); settle(() => reject(err)) }) + fileStream.on('error', (err) => { fs.unlink(tempPath, () => { }); fail(err) }) }) - req.on('error', (err) => { fs.unlink(tempPath, () => { }); settle(() => reject(err)) }) + req.on('error', (err) => { fs.unlink(tempPath, () => { }); fail(err) }) }) } From 2fd6ece938fa9746c51ca55d37d16be276bddc38 Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Sun, 14 Jun 2026 20:06:11 +0800 Subject: [PATCH 13/26] fix: copy existing cache to download folder --- public/music/js/download_manager.js | 13 ++++++---- src/server/fileCache.ts | 38 +++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index 4339da6..be23962 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -887,7 +887,7 @@ class DownloadManager { } clearCompleted() { - this.tasks = this.tasks.filter(t => t.status !== 'finished'); + this.tasks = this.tasks.filter(t => t.status !== 'finished' && t.status !== 'exists'); this.renderList(); this.saveTasks(); } @@ -895,8 +895,11 @@ class DownloadManager { clearAll() { // 先弹确认框 if (typeof showSelect === 'function') { - showSelect('停止并清空任务', '确认要立即停止所有进行中的任务并清空列表吗?', { - confirmText: '确认停止', + const hasActiveTasks = this.tasks.some(t => t.status === 'downloading' || t.status === 'waiting'); + const title = hasActiveTasks ? '停止并清空任务' : '清空任务列表'; + const message = hasActiveTasks ? '确认要立即停止所有进行中的任务并清空列表吗?' : '确认要清空所有下载任务记录吗?'; + showSelect(title, message, { + confirmText: hasActiveTasks ? '确认停止' : '确认清空', danger: true }).then(confirmed => { if (!confirmed) return; @@ -945,7 +948,7 @@ class DownloadManager { isServer: t.isServer, quality: t.quality, status: t.status === 'downloading' ? (t.isServer ? 'waiting' : 'waiting') : t.status, - progress: t.status === 'finished' ? 100 : (t.isServer ? t.progress : 0), + progress: (t.status === 'finished' || t.status === 'exists') ? 100 : (t.isServer ? t.progress : 0), errorMsg: t.errorMsg || '', retryCount: t.retryCount || 0, maxRetries: t.maxRetries || 2 @@ -1005,7 +1008,7 @@ class DownloadManager { active++; } // 所有任务都纳入进度计算(server 任务可能 totalBytes=0,但 progress/status 是已知的) - if (t.status === 'finished') { + if (t.status === 'finished' || t.status === 'exists') { pctTotal += 100; pctCount++; } else if (t.status === 'downloading' || t.status === 'waiting') { diff --git a/src/server/fileCache.ts b/src/server/fileCache.ts index 0746e4b..9f0009d 100644 --- a/src/server/fileCache.ts +++ b/src/server/fileCache.ts @@ -1247,8 +1247,42 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str const result = checkCache({ ...songInfo, quality, exactQuality: true }, username, false) if (result.exists && !result.isCollision) { - console.log(`[FileCache] Song already exists, skipping download: ${result.filename}`) - // 通知前端轮询:文件已存在,视为立即完成 + const targetFolder: 'cache' | 'music' = isOnlyDownload ? 'music' : 'cache' + if (result.folder === targetFolder) { + console.log(`[FileCache] Song already exists in ${targetFolder}, skipping download: ${result.filename}`) + // 通知前端轮询:目标目录文件已存在,视为立即完成 + cacheProgress.set(songKey, { progress: 100, status: 'exists' }) + setTimeout(() => cacheProgress.delete(songKey), 30000) + return Promise.resolve() + } + + if (isOnlyDownload && result.folder === 'cache' && result.path) { + const ext = path.extname(result.filename || result.path) || '.mp3' + const finalPath = path.join(dir, baseName + ext) + if (!fs.existsSync(finalPath)) { + fs.copyFileSync(result.path, finalPath) + } + + const metadata = extractSongMetadata(songInfo) + const id = metadata.id || String(songInfo.id || songInfo.songmid) + const normalizedUsername = (username && username !== '_open' && username !== 'default') ? username : '_open' + const stat = fs.statSync(finalPath) + indexManager.update(normalizedUsername, { + id, songmid: id, name: metadata.name, singer: metadata.singer, + album: metadata.album, albumId: metadata.albumId, img: metadata.img, + interval: metadata.interval, source: metadata.source, + quality: quality || result.quality || 'unknown', filename: path.basename(finalPath), + folder: 'music', mtime: Date.now(), size: stat.size, + ext: ext.replace('.', ''), hasCover: false, hasLyric: false + }, 'music') + + console.log(`[FileCache] Copied cached song to music folder: ${path.basename(finalPath)}`) + cacheProgress.set(songKey, { progress: 100, status: 'finished', total: stat.size, received: stat.size }) + setTimeout(() => cacheProgress.delete(songKey), 30000) + return Promise.resolve() + } + + console.log(`[FileCache] Song already exists in ${result.folder}, skipping download: ${result.filename}`) cacheProgress.set(songKey, { progress: 100, status: 'exists' }) setTimeout(() => cacheProgress.delete(songKey), 30000) return Promise.resolve() From a5588f0b7b102105926302ee64a5847e013d91b6 Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Sun, 14 Jun 2026 20:17:54 +0800 Subject: [PATCH 14/26] fix: preserve download naming and lyrics --- public/music/app.js | 13 +++++++- public/music/js/download_manager.js | 20 +++++++++-- src/server/fileCache.ts | 52 +++++++++++++++++++++++++---- src/server/server.ts | 6 +++- 4 files changed, 79 insertions(+), 12 deletions(-) diff --git a/public/music/app.js b/public/music/app.js index c333f80..da990bb 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -3467,13 +3467,24 @@ async function triggerServerCache(song, url, quality) { const adminPass = localStorage.getItem('lx_admin_password'); if (adminPass) headers['x-frontend-auth'] = adminPass; + const coverUrl = typeof getImgUrl === 'function' ? getImgUrl(song) : (song.img || song.meta?.picUrl || ''); + const songInfoForCache = { + ...song, + img: song.img || coverUrl, + meta: { + ...(song.meta || {}), + picUrl: song.meta?.picUrl || coverUrl + } + }; + await fetch('/api/music/cache/download', { method: 'POST', headers: headers, body: JSON.stringify({ - songInfo: song, + songInfo: songInfoForCache, url, quality, + namingPattern: window.settings?.serverCacheNamingPattern || 'simple', embedLyric: !!(window.settings?.embedLyricToFile ?? true) }) }); diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index be23962..1cf6cc4 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -305,6 +305,19 @@ class DownloadManager { '/music/assets/logo.svg'; } + getSongInfoForServer(song) { + const cover = this.getSongCover(song); + const normalizedCover = cover && cover !== '/music/assets/logo.svg' ? cover : ''; + return { + ...song, + img: song.img || normalizedCover, + meta: { + ...(song.meta || {}), + picUrl: song.meta?.picUrl || normalizedCover + } + }; + } + // [Unified] Status generator for drawer lists getStatusHtml(icon, text, isSpin = false) { return ` @@ -441,10 +454,11 @@ class DownloadManager { method: 'POST', headers, body: JSON.stringify({ - songInfo: resolvedSong, + songInfo: this.getSongInfoForServer(resolvedSong), url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, + namingPattern: window.settings?.serverCacheNamingPattern || 'simple', cacheLyric: window.settings?.enableServerLyricCache !== false, embedLyric: !!(window.settings?.embedLyricToFile ?? true) }) @@ -757,7 +771,7 @@ class DownloadManager { const res = await fetch('/api/music/cache/download', { method: 'POST', headers, - body: JSON.stringify({ songInfo: resolvedSong, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, cacheLyric: window.settings?.enableServerLyricCache !== false, embedLyric: !!(window.settings?.embedLyricToFile ?? true) }) + body: JSON.stringify({ songInfo: this.getSongInfoForServer(resolvedSong), url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, namingPattern: window.settings?.serverCacheNamingPattern || 'simple', cacheLyric: window.settings?.enableServerLyricCache !== false, embedLyric: !!(window.settings?.embedLyricToFile ?? true) }) }); if (!res.ok) throw new Error('服务器拒绝请求'); @@ -862,7 +876,7 @@ class DownloadManager { const res = await fetch('/api/music/cache/download', { method: 'POST', headers, - body: JSON.stringify({ songInfo: resolvedSong, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, cacheLyric: window.settings?.enableServerLyricCache !== false, embedLyric: !!(window.settings?.embedLyricToFile ?? true) }) + body: JSON.stringify({ songInfo: this.getSongInfoForServer(resolvedSong), url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, namingPattern: window.settings?.serverCacheNamingPattern || 'simple', cacheLyric: window.settings?.enableServerLyricCache !== false, embedLyric: !!(window.settings?.embedLyricToFile ?? true) }) }); if (!res.ok) throw new Error('服务器拒绝缓存'); diff --git a/src/server/fileCache.ts b/src/server/fileCache.ts index 9f0009d..4182fc4 100644 --- a/src/server/fileCache.ts +++ b/src/server/fileCache.ts @@ -1186,8 +1186,26 @@ export const saveLyricCache = (songInfo: any, lyricsObj: any, username?: string, let quality = songInfo.quality || 'unknown' let dir: string - // First check where the audio file actually exists - const audioResult = checkCache({ ...songInfo, exactQuality: false }, username, false) + const normalizedUsername = (username && username !== '_open' && username !== 'default') ? username : '_open' + const id = normalizeSongId(songInfo) + const preferredFolders: Array<'cache' | 'music'> = isOnlyDownload ? ['music', 'cache'] : ['cache', 'music'] + let audioResult: any = { exists: false } + for (const folder of preferredFolders) { + const cached = indexManager.get(normalizedUsername, id, folder, songInfo.quality, false) + if (!cached?.filename) continue + const root = getCacheDir(normalizedUsername, folder === 'music') + const filePath = path.join(root, cached.filename) + if (fs.existsSync(filePath)) { + audioResult = { + exists: true, + path: filePath, + quality: cached.quality, + folder, + filename: cached.filename + } + break + } + } if (audioResult.exists && audioResult.path) { // If audio exists, save lyric in the same folder @@ -1217,10 +1235,7 @@ export const saveLyricCache = (songInfo: any, lyricsObj: any, username?: string, console.log(`[FileCache] Lyric cached saved to: ${finalPath}`) // Update index — use normalizeSongId to ensure the ID has source prefix, matching index keys - const id = normalizeSongId(songInfo) - const normalizedUsername = (username && username !== '_open' && username !== 'default') ? username : '_open' - // Update both cache and music folders in case the audio was found in either - const foldersToUpdate: Array<'cache' | 'music'> = ['cache', 'music'] + const foldersToUpdate: Array<'cache' | 'music'> = isOnlyDownload ? ['music', 'cache'] : ['cache', 'music'] for (const folder of foldersToUpdate) { const existing = indexManager.get(normalizedUsername, id, folder, quality) if (existing) { @@ -1267,13 +1282,36 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str const id = metadata.id || String(songInfo.id || songInfo.songmid) const normalizedUsername = (username && username !== '_open' && username !== 'default') ? username : '_open' const stat = fs.statSync(finalPath) + let hasCover = false + let hasEmbedLyric = false + try { + const tagger = new MusicTagger() + tagger.loadPath(finalPath) + hasCover = !!(tagger.pictures && tagger.pictures.length > 0) + const lyricsInTag = tagger.lyrics + hasEmbedLyric = !!(lyricsInTag && lyricsInTag.trim().length > 10) + tagger.dispose() + } catch (e) { } + + let lyricFilename: string | undefined + const sourceLyricPath = result.path.substring(0, result.path.length - ext.length) + '.lrc' + if (fs.existsSync(sourceLyricPath)) { + const targetLyricPath = path.join(dir, baseName + '.lrc') + fs.copyFileSync(sourceLyricPath, targetLyricPath) + lyricFilename = path.basename(targetLyricPath) + } + indexManager.update(normalizedUsername, { id, songmid: id, name: metadata.name, singer: metadata.singer, album: metadata.album, albumId: metadata.albumId, img: metadata.img, interval: metadata.interval, source: metadata.source, quality: quality || result.quality || 'unknown', filename: path.basename(finalPath), folder: 'music', mtime: Date.now(), size: stat.size, - ext: ext.replace('.', ''), hasCover: false, hasLyric: false + lyricFilename, + ext: ext.replace('.', ''), + hasCover, + hasLyric: !!lyricFilename, + hasEmbedLyric }, 'music') console.log(`[FileCache] Copied cached song to music folder: ${path.basename(finalPath)}`) diff --git a/src/server/server.ts b/src/server/server.ts index ad566ee..c8376a6 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -2329,12 +2329,16 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro if (pathname === '/api/music/cache/download' && req.method === 'POST') { void readBody(req).then(body => { try { - const { songInfo, url, quality, enableOnlyDownloadMode, cacheLyric, embedLyric } = JSON.parse(body) + const { songInfo, url, quality, enableOnlyDownloadMode, namingPattern, cacheLyric, embedLyric } = JSON.parse(body) if (!songInfo || !url) { res.writeHead(400) res.end('Missing params') return } + if (namingPattern) { + fileCache.setNamingPattern(namingPattern) + if (global.lx.config) global.lx.config['cache.namingPattern'] = namingPattern + } // Fire and forget (background download) with Abort support const reqUsername = (req.headers['x-user-name'] as string) || '' From fc7c7951543ea72096a92064a81937682b24b2d6 Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Sun, 14 Jun 2026 21:30:44 +0800 Subject: [PATCH 15/26] fix: address review feedback --- public/music/app.js | 57 ++++++++++++++++++++++------- public/music/js/batch_pagination.js | 36 +++++++++++------- public/music/js/single_song_ops.js | 6 ++- src/server/fileCache.ts | 5 ++- src/server/server.ts | 8 ++-- 5 files changed, 77 insertions(+), 35 deletions(-) diff --git a/public/music/app.js b/public/music/app.js index da990bb..1bc75a7 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -149,6 +149,16 @@ window.settings = settings; // 显式挂载到 window window.networkListUpdateMap = new Set(); let networkListAutoCheckTimer = null; +function escapeHtmlText(value) { + return String(value ?? '').replace(/[&<>"']/g, ch => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + })[ch]); +} + function parseNetworkListAutoCheckInterval(value) { const minIntervalMs = 30 * 1000; if (value === undefined || value === null) return 0; @@ -239,13 +249,15 @@ async function checkNetworkListUpdates(manual = false) { } if (manual) { + const changedListNames = changedLists.map(escapeHtmlText); + const failedListNames = failedLists.map(escapeHtmlText); if (changedLists.length > 0) { - showSuccess(`检测到 ${changedLists.length} 个歌单已更新:${changedLists.join('、')}`); - } else { + showSuccess(`检测到 ${changedLists.length} 个歌单已更新:${changedListNames.join('、')}`); + } else if (failedLists.length === 0) { showSuccess('所有网络歌单均为最新状态'); } if (failedLists.length > 0) { - showError(`部分歌单检测失败:${failedLists.join('、')}`); + showError(`部分歌单检测失败:${failedListNames.join('、')}`); } } else if (changedLists.length > 0 && window.showToast) { showToast('info', `检测到 ${changedLists.length} 个网络歌单有更新`, 5000); @@ -3817,6 +3829,15 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr let finalUrl = urlResult.url; currentQuality = urlResult.quality; currentSourceType = urlResult.sourceType; + const playbackSong = (urlResult.switchedSource && urlResult.songInfo) ? urlResult.songInfo : song; + if (playbackSong !== song) { + currentPlayingSong = playbackSong; + window.currentPlayingSong = playbackSong; + if (currentRecoveryState) currentRecoveryState.currentSong = playbackSong; + updatePlayerInfo(playbackSong); + updateMediaSessionMetadata(playbackSong); + fetchLyric(playbackSong, currentQuality); + } // [Sync] 确定了最终播放音质后,直接以正确音质重写服务器端歌词缓存文件名 // 注意:不能再调用 fetchLyric(song),因为歌词已就绪时 fetchLyric 会提前返回, @@ -3830,7 +3851,7 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr method: 'POST', headers: _lyricHeaders, body: JSON.stringify({ - songInfo: { ...song, quality: currentQuality }, + songInfo: { ...playbackSong, quality: currentQuality }, lyricsObj: { lyric: currentRawLrc, tlyric: currentRawTlrc, rlyric: currentRawRlrc, lxlyric: currentRawKlrc } }) }).catch(e => console.warn('[Lyric] 音质确定后重写服务端缓存失败:', e)); @@ -3843,8 +3864,8 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr if (currentSourceType !== 'normal') { const retryHandler = () => { console.warn(`[Player] ${currentSourceType} link failed, retrying online...`); - if (currentSourceType === 'cache') localStorage.removeItem(`lx_url_${cleanSongData(song).id}_${targetQuality}`); - playSong(song, index, targetQuality, noPlay, currentSourceType === 'server_cache' ? 'local_retry' : true); + if (currentSourceType === 'cache') localStorage.removeItem(`lx_url_${cleanSongData(playbackSong).id}_${targetQuality}`); + playSong(playbackSong, index, targetQuality, noPlay, currentSourceType === 'server_cache' ? 'local_retry' : true); }; audio.addEventListener('error', retryHandler, { once: true }); const cleanup = () => audio.removeEventListener('error', retryHandler); @@ -3878,10 +3899,10 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr updatePlayButton(true); // Save history and handle list logic - savePlayHistory(song, currentQuality); + savePlayHistory(playbackSong, currentQuality); const finalAdd = shouldAddToDefault !== null ? shouldAddToDefault : (currentPlayingScope === 'network' || currentPlayingScope === 'songlist' || currentPlayingScope === 'leaderboard'); if (finalAdd) { - addToDefaultList(song); + addToDefaultList(playbackSong); // 切换逻辑说明: // - 搜索结果(network):updatePlaylist 把队列设为搜索结果,开启设置才把队列切换到 defaultList // - 歌单/排行榜(songlist/leaderboard):updatePlaylist 已把队列设为歌单/排行榜, @@ -5213,6 +5234,15 @@ async function updateSetting(key, value) { } } + if (key === 'networkListAutoCheckInterval') { + const intervalMs = parseNetworkListAutoCheckInterval(value); + if (intervalMs === null) { + showError('无效的自动检测间隔,请使用 30m / 6h / 1d 等格式'); + syncSettingsUI(key, settings[key]); + return; + } + } + settings[key] = value; window.settings = settings; // 确保全局引用同步 try { @@ -5224,10 +5254,6 @@ async function updateSetting(key, value) { // 实时同步 UI 并应用效果 syncSettingsUI(key, value); if (key === 'networkListAutoCheckInterval' || key === 'autoUpdateNetworkList') { - const intervalMs = parseNetworkListAutoCheckInterval(settings.networkListAutoCheckInterval); - if (key === 'networkListAutoCheckInterval' && intervalMs === null) { - showError('无效的自动检测间隔,请使用 30m / 6h / 1d 等格式'); - } setupNetworkListAutoCheck(); } @@ -7758,6 +7784,7 @@ async function fetchSettingsFromServer() { localStorage.setItem('lx_settings', JSON.stringify(settings)); // Update UI syncSettingsUI(); + setupNetworkListAutoCheck(); if (typeof showSuccess === 'function') { showSuccess('已从服务器恢复设置'); } @@ -8784,7 +8811,8 @@ async function handleRefreshList(listId, event, silent = false) { } if (!silent) { - const confirmed = await showSelect('更新歌单', `是否更新当前歌单 "${list.name}"?\n(确认后将重新从服务器拉取歌单并覆盖当前内容)`, { + const safeListName = escapeHtmlText(list.name || list.id || list.sourceListId || ''); + const confirmed = await showSelect('更新歌单', `是否更新当前歌单 "${safeListName}"?\n(确认后将重新从服务器拉取歌单并覆盖当前内容)`, { confirmText: '确定更新', confirmColor: 'bg-emerald-500' }); @@ -10074,7 +10102,8 @@ async function handleTogglePlaylist(listId, btnElement) { } // Cleanup selection - if (typeof deselectAll === 'function') deselectAll(); + if (typeof exitBatchMode === 'function') exitBatchMode(); + else if (typeof deselectAll === 'function') deselectAll(); } catch (e) { console.error('[BatchCollect] Sync failed, reverting or refreshing:', e); diff --git a/public/music/js/batch_pagination.js b/public/music/js/batch_pagination.js index 4e780c4..e6c3960 100644 --- a/public/music/js/batch_pagination.js +++ b/public/music/js/batch_pagination.js @@ -92,15 +92,10 @@ function selectAllVisible() { updateBatchToolbar(); } -function deselectAll() { - window.batchMode = false; +function clearSelection() { window.selectedItems.clear(); window.selectedSongObjects.clear(); - const batchToolbar = document.getElementById('batch-toolbar'); - const slToolbar = document.getElementById('sl-batch-toolbar'); - const lbBatchToolbar = document.getElementById('lb-batch-toolbar'); - // updateBatchToolbar() 会被调用,这里也主动清零防遗漏 const countEl = document.getElementById('batch-selected-count'); const slCountEl = document.getElementById('sl-batch-selected-count'); @@ -109,6 +104,22 @@ function deselectAll() { if (slCountEl) slCountEl.textContent = '0'; if (lbCountEl) lbCountEl.textContent = '0'; + // 重新渲染UI + refreshBatchUI(); + if (window.LeaderboardManager && document.getElementById('view-leaderboard') && !document.getElementById('view-leaderboard').classList.contains('hidden')) { + window.LeaderboardManager.renderSongs(); + } + updateBatchToolbar(); +} + +function exitBatchMode() { + window.batchMode = false; + clearSelection(); + + const batchToolbar = document.getElementById('batch-toolbar'); + const slToolbar = document.getElementById('sl-batch-toolbar'); + const lbBatchToolbar = document.getElementById('lb-batch-toolbar'); + if (batchToolbar) batchToolbar.classList.add('hidden'); if (slToolbar) slToolbar.classList.add('hidden'); if (lbBatchToolbar) lbBatchToolbar.classList.add('hidden'); @@ -116,13 +127,10 @@ function deselectAll() { // 恢复被隐藏的分页控件 (在排行榜中) const lbPagination = document.getElementById('lb-pagination'); if (lbPagination) lbPagination.classList.remove('hidden'); +} - // 重新渲染UI - refreshBatchUI(); - if (window.LeaderboardManager && document.getElementById('view-leaderboard') && !document.getElementById('view-leaderboard').classList.contains('hidden')) { - window.LeaderboardManager.renderSongs(); - } - updateBatchToolbar(); +function deselectAll() { + clearSelection(); } function updateBatchToolbar() { @@ -251,7 +259,7 @@ async function batchDeleteFromList() { } // Clear selection and exit batch mode - deselectAll(); + exitBatchMode(); } // Helper: Get current active list ID @@ -448,6 +456,8 @@ async function handleBatchCollect() { window.handleBatchSelect = handleBatchSelect; window.toggleBatchMode = toggleBatchMode; window.selectAllVisible = selectAllVisible; +window.clearSelection = clearSelection; +window.exitBatchMode = exitBatchMode; window.deselectAll = deselectAll; window.batchDeleteFromList = batchDeleteFromList; window.handleBatchCollect = handleBatchCollect; diff --git a/public/music/js/single_song_ops.js b/public/music/js/single_song_ops.js index 9d01bde..05a6120 100644 --- a/public/music/js/single_song_ops.js +++ b/public/music/js/single_song_ops.js @@ -363,7 +363,8 @@ async function batchDownloadFromList() { showInfo(`已将 ${songsToDownload.length} 项任务添加到下载列表,您可以前往右侧下载管理面板查看进度`); // Clean up selection optionally - if (typeof deselectAll === 'function') deselectAll(); + if (typeof exitBatchMode === 'function') exitBatchMode(); + else if (typeof deselectAll === 'function') deselectAll(); } else { showError('下载管理器未就绪'); } @@ -410,7 +411,8 @@ async function batchDownloadFromList() { }); window.SystemDownloadManager.addTasks(tasks); - if (typeof deselectAll === 'function') deselectAll(); + if (typeof exitBatchMode === 'function') exitBatchMode(); + else if (typeof deselectAll === 'function') deselectAll(); showInfo(`已将 ${songsToDownload.length} 首歌曲加入缓存队列`); } } diff --git a/src/server/fileCache.ts b/src/server/fileCache.ts index 4182fc4..15fd9da 100644 --- a/src/server/fileCache.ts +++ b/src/server/fileCache.ts @@ -1295,7 +1295,7 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str let lyricFilename: string | undefined const sourceLyricPath = result.path.substring(0, result.path.length - ext.length) + '.lrc' - if (fs.existsSync(sourceLyricPath)) { + if (shouldCacheLyric && fs.existsSync(sourceLyricPath)) { const targetLyricPath = path.join(dir, baseName + '.lrc') fs.copyFileSync(sourceLyricPath, targetLyricPath) lyricFilename = path.basename(targetLyricPath) @@ -1335,6 +1335,7 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str let settled = false const fail = (err: Error) => { + if (settled) return const message = err.message || 'Download failed' cacheProgress.set(songKey, { progress: 0, status: 'error', errorMsg: message }) settle(() => reject(err)) @@ -1449,8 +1450,8 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str try { const lyricText = await _lyricFetcher({ ...songInfo, quality }) if (lyricText) { - const lyricsObj = parseLyrics(lyricText) if (shouldCacheLyric) { + const lyricsObj = parseLyrics(lyricText) saveLyricCache({ ...songInfo, quality }, lyricsObj, username, isOnlyDownload) } if (shouldEmbedLyric) { diff --git a/src/server/server.ts b/src/server/server.ts index c8376a6..8fc1f26 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -2335,10 +2335,6 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro res.end('Missing params') return } - if (namingPattern) { - fileCache.setNamingPattern(namingPattern) - if (global.lx.config) global.lx.config['cache.namingPattern'] = namingPattern - } // Fire and forget (background download) with Abort support const reqUsername = (req.headers['x-user-name'] as string) || '' @@ -2354,6 +2350,10 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro } username = verified } + if (namingPattern) { + fileCache.setNamingPattern(namingPattern) + if (global.lx.config) global.lx.config['cache.namingPattern'] = namingPattern + } const songKey = fileCache.normalizeSongId(songInfo) + '_' + (quality || 'unknown') console.log(`[Cache] Registering active task: ${songKey} for user: "${username}"`) From d07251afc68d62f41ca09a4bac6de2abdb9d36f0 Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Sun, 14 Jun 2026 23:32:00 +0800 Subject: [PATCH 16/26] fix: avoid duplicate cache writes during download fallback --- public/music/app.js | 163 +++++++++++++++++++++++----- public/music/js/download_manager.js | 35 ++++-- 2 files changed, 158 insertions(+), 40 deletions(-) diff --git a/public/music/app.js b/public/music/app.js index 1bc75a7..7234376 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -2832,7 +2832,7 @@ async function resolveSongUrl(song, quality, isSilent = false, isRetry = false, } } - const fallbackRetryMode = isRetry === 'local_retry' ? 'local_retry' : true; + const fallbackRetryMode = (isRetry === 'local_retry' || isRetry === 'download') ? isRetry : true; for (const step of steps) { if (step === 'degrade') { @@ -2870,12 +2870,124 @@ async function resolveSongUrl(song, quality, isSilent = false, isRetry = false, } } +async function resolveDownloadSongUrl(song, quality, isSilent = true) { + const tried = new Set(); + let lastError = null; + + const tryResolveCandidate = async (candidateSong, preferredQuality) => { + let candidateQuality = window.QualityManager + ? window.QualityManager.getBestQuality(candidateSong, preferredQuality || settings.preferredQuality || '320k') + : (preferredQuality || '320k'); + + while (candidateQuality) { + const key = `${candidateSong.source || ''}_${candidateSong.id || candidateSong.songmid || ''}_${candidateQuality}`; + if (tried.has(key)) break; + tried.add(key); + + try { + const result = await fetchSongUrl(candidateSong, candidateQuality, 'download', isSilent); + if (result.errorMsg) throw new Error(result.errorMsg); + return result; + } catch (err) { + lastError = err; + console.warn(`[DownloadResolve] 解析失败: ${candidateSong.name} via ${candidateSong.source} (${candidateQuality})`, err); + if (!window.QualityManager || settings.enableAutoDegradeQuality === false) break; + candidateQuality = window.QualityManager.getNextLowerQuality(candidateQuality, candidateSong); + } + } + + return null; + }; + + const originalResult = await tryResolveCandidate(song, quality); + if (originalResult) return originalResult; + + if (settings.enableAutoSwitchSource === false) { + throw lastError || new Error('解析失败'); + } + + const matches = await findOtherSourceMatches(song, isSilent, { ignoreSupportedFilter: true }); + for (const matchedSong of matches) { + const matchedResult = await tryResolveCandidate(matchedSong, quality); + if (matchedResult) { + return { + ...matchedResult, + songInfo: matchedSong, + switchedSource: true, + originalSource: song.source + }; + } + } + + throw lastError || new Error('未找到可下载的备选源'); +} + +function normalizeSongMatchText(value) { + return String(value || '') + .toLowerCase() + .replace(/[((].*?[))]/g, '') + .replace(/[\s·・,,.。!!??::;;'"‘’“”《》<>【】[\]()()\-_/\\]/g, ''); +} + +function isSingerMatch(sourceSinger, targetSinger) { + const sourceText = normalizeSongMatchText(sourceSinger); + const targetText = normalizeSongMatchText(targetSinger); + if (!sourceText || !targetText) return true; + if (sourceText.includes(targetText) || targetText.includes(sourceText)) return true; + + const splitSinger = value => String(value || '') + .toLowerCase() + .split(/[、,,/&/|;;]+/) + .map(normalizeSongMatchText) + .filter(Boolean); + const sourceParts = splitSinger(sourceSinger); + const targetParts = splitSinger(targetSinger); + return sourceParts.some(sourcePart => targetParts.some(targetPart => sourcePart.includes(targetPart) || targetPart.includes(sourcePart))); +} + +function getSongMatchScore(item, song) { + const targetName = normalizeSongMatchText(song.name); + const itemName = normalizeSongMatchText(item.name); + if (!targetName || !itemName) return -1; + if (!itemName.includes(targetName) && !targetName.includes(itemName)) return -1; + + if (!isSingerMatch(item.singer, song.singer)) return -1; + + const targetDuration = timeToSeconds(song.interval); + const itemDuration = timeToSeconds(item.interval); + let durationScore = 0; + if (targetDuration > 0 && itemDuration > 0) { + const durationDiff = Math.abs(targetDuration - itemDuration); + if (durationDiff > 8) return -1; + durationScore = 8 - durationDiff; + } + + let nameScore = 0; + if (itemName === targetName) nameScore = 20; + else if (itemName.includes(targetName) || targetName.includes(itemName)) nameScore = 10; + + const sameAlbum = item.albumName && song.albumName && normalizeSongMatchText(item.albumName) === normalizeSongMatchText(song.albumName); + return nameScore + durationScore + (sameAlbum ? 3 : 0); +} + +function getSongDurationDiff(item, song) { + const targetDuration = timeToSeconds(song.interval); + const itemDuration = timeToSeconds(item.interval); + if (targetDuration <= 0 || itemDuration <= 0) return null; + return Math.abs(targetDuration - itemDuration); +} + /** * 跨平台寻找相同歌曲的匹配逻辑 * 基本规则:歌名+歌手+时长匹配 */ async function findOtherSourceMatch(song, isSilent = false) { - if (!song.name || !song.singer) return null; + const matches = await findOtherSourceMatches(song, isSilent); + return matches[0] || null; +} + +async function findOtherSourceMatches(song, isSilent = false, options = {}) { + if (!song.name || !song.singer) return []; try { // 1. 获取当前自定义源支持解析的平台(支持的平台) @@ -2898,7 +3010,7 @@ async function findOtherSourceMatch(song, isSilent = false) { // 3. 过滤出自定义源支持解析的平台 let searchSources = searchSourcesOrdered; - if (supportedPlatforms) { + if (supportedPlatforms && !options.ignoreSupportedFilter) { searchSources = searchSourcesOrdered.filter(s => supportedPlatforms.has(s)); } @@ -2906,7 +3018,7 @@ async function findOtherSourceMatch(song, isSilent = false) { if (searchSources.length === 0) { console.log(`[AutoSource] 换源跳过:没有其他自定义源支持的平台。当前源: ${song.source}`); if (!isSilent) showError('未找到自定义源下支持的平台下的对应歌曲'); - return null; + return []; } const query = `${song.name} ${song.singer}`; @@ -2925,39 +3037,27 @@ async function findOtherSourceMatch(song, isSilent = false) { const allResults = await Promise.all(searchPromises); const flatResults = allResults.flat(); - if (flatResults.length === 0) return null; + if (flatResults.length === 0) return []; - const targetDuration = timeToSeconds(song.interval); - const cleanedTargetName = song.name.toLowerCase().trim(); + const matches = []; // 匹配算法 for (const item of flatResults) { - const itemDuration = timeToSeconds(item.interval); - const durationDiff = Math.abs(targetDuration - itemDuration); - - // 1. 时长校验:误差在 5 秒以内 - if (durationDiff > 5) continue; - - // 2. 歌名校验:简单包含或相等(忽略大小写) - const cleanedItemName = item.name.toLowerCase().trim(); - if (!cleanedItemName.includes(cleanedTargetName) && !cleanedTargetName.includes(cleanedItemName)) continue; - - // 3. 歌手校验:简单比对 - if (item.singer && song.singer) { - const cleanedItemSinger = item.singer.toLowerCase(); - const cleanedTargetSinger = song.singer.toLowerCase(); - if (!cleanedItemSinger.includes(cleanedTargetSinger) && !cleanedTargetSinger.includes(cleanedItemSinger)) continue; - } + const score = getSongMatchScore(item, song); + if (score < 0) continue; - console.log(`[AutoSource] 匹配成功: ${item.name} via ${item.source} (时长误差: ${durationDiff}s)`); - return item; + const durationDiff = getSongDurationDiff(item, song); + console.log(`[AutoSource] 匹配成功: ${item.name} via ${item.source} (score: ${score}, 时长误差: ${durationDiff === null ? '未知' : `${durationDiff}s`})`); + matches.push({ ...item, _matchScore: score }); } - console.log(`[AutoSource] 未找到合适的匹配结果 (Total searched: ${flatResults.length})`); - return null; + if (matches.length === 0) { + console.log(`[AutoSource] 未找到合适的匹配结果 (Total searched: ${flatResults.length})`); + } + return matches.sort((a, b) => (b._matchScore || 0) - (a._matchScore || 0)); } catch (e) { console.warn('[AutoSource] 匹配逻辑执行出错:', e); - return null; + return []; } } @@ -3057,7 +3157,8 @@ async function fetchSongUrl(song, quality, isRetry = false, isSilent = false) { const cleanedSong = cleanSongData(song); const cacheKey = `lx_url_${cleanedSong.id}_${quality}`; - const allowServerCache = settings.preferServerCache !== false && isRetry !== 'local_retry'; + const shouldBypassServerCache = isRetry === 'local_retry' || isRetry === 'download'; + const allowServerCache = settings.preferServerCache !== false && !shouldBypassServerCache; if (allowServerCache) { let cacheResult = await checkServerCache(cleanedSong, quality, !!isRetry); if (cacheResult.exists && !cacheResult.isCollision) { @@ -3136,7 +3237,7 @@ async function fetchSongUrl(song, quality, isRetry = false, isSilent = false) { updateStorageStatsUI(); } catch (e) { } } - if (settings.enableServerCache && !finalUrl.includes('/api/music/cache/file/')) { + if (settings.enableServerCache && isRetry !== 'download' && !finalUrl.includes('/api/music/cache/file/')) { // [Fix] 传递原始 result.url 而非经过 applyAutoProxy 处理后的相对代理路径, // 否则后端下载器会因无法识别相对路径而报 ERR_INVALID_URL 错误。 triggerServerCache(song, result.url, quality); @@ -6153,6 +6254,8 @@ window.removeCacheItem = removeCacheItem; window.clearServerCache = clearServerCache; window.handleHotSearchClick = handleHotSearchClick; window.playSong = playSong; +window.resolveSongUrl = resolveSongUrl; +window.resolveDownloadSongUrl = resolveDownloadSongUrl; window.togglePlay = togglePlay; window.playNext = playNext; window.changeProxyPlayback = changeProxyPlayback; diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index 1cf6cc4..c5638a0 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -54,6 +54,17 @@ class DownloadManager { return Math.min(5, Math.max(1, parsed)); } + shouldAutoSyncLyric(task) { + return !!( + task && + task.isServer && + task.status === 'finished' && + window.requestServerLyricCache && + window.settings?.enableServerLyricCache !== false && + window.settings?.enableOnlyDownloadMode !== true + ); + } + async pollServerProgress() { // [Move to top] 对已完成但还未检测过歌词的云端任务,执行检测 // 这样即使当前没有正在下载的任务,刷新页面后也能触发一次歌词状态刷新 @@ -139,7 +150,7 @@ class DownloadManager { task.progress = 100; task.errorMsg = ''; // 成功完成后触发歌词同步(补充) - if (window.requestServerLyricCache && task.status === 'finished') { + if (this.shouldAutoSyncLyric(task)) { window.requestServerLyricCache(task.song, task.quality).then(() => { // 延时一下再检查,确保后端写入完成 setTimeout(() => this.checkTaskLyric(task), 2000); @@ -169,7 +180,7 @@ class DownloadManager { task.speed = 0; // 成功完成后触发歌词同步(补充) - if (window.requestServerLyricCache) { + if (this.shouldAutoSyncLyric(task)) { window.requestServerLyricCache(task.song, task.quality).then(() => { setTimeout(() => this.checkTaskLyric(task), 2000); }); @@ -429,13 +440,14 @@ class DownloadManager { this.renderTask(task); try { - if (typeof resolveSongUrl !== 'function') throw new Error('resolveSongUrl missing'); + const downloadResolver = window.resolveDownloadSongUrl || window.resolveSongUrl; + if (typeof downloadResolver !== 'function') throw new Error('resolveSongUrl missing'); // 1. Resolve URL const quality = task.quality || (window.QualityManager ? window.QualityManager.getBestQuality(task.song, window.settings?.preferredQuality || '320k') : '320k'); task.quality = quality; - const result = await resolveSongUrl(task.song, quality, true, 'local_retry'); + const result = await downloadResolver(task.song, quality, true); if (!result || !result.url) throw new Error('解析失败'); const resolvedSong = result.songInfo || task.song; @@ -513,8 +525,9 @@ class DownloadManager { if (path.includes('.')) ext = path.split('.').pop(); } catch (e) { } } else { - if (typeof resolveSongUrl !== 'function') throw new Error('resolveSongUrl missing'); - const resolveData = await resolveSongUrl(task.song, quality, true, true); + const downloadResolver = window.resolveDownloadSongUrl || window.resolveSongUrl; + if (typeof downloadResolver !== 'function') throw new Error('resolveSongUrl missing'); + const resolveData = await downloadResolver(task.song, quality, true); if (!resolveData || !resolveData.url) throw new Error('No download URL found'); const resolvedSong = resolveData.songInfo || task.song; @@ -747,13 +760,14 @@ class DownloadManager { // 云端任务:恢复时由于后端进程可能已中止,需要重新触发解析与下载 (async () => { try { - if (typeof resolveSongUrl !== 'function') throw new Error('resolveSongUrl not available'); + const downloadResolver = window.resolveDownloadSongUrl || window.resolveSongUrl; + if (typeof downloadResolver !== 'function') throw new Error('resolveSongUrl not available'); // 重新获取音质编码(处理显示名称) let quality = task.quality; if (window.QualityManager) { quality = window.QualityManager.getBestQuality(task.song, quality); } - const result = await resolveSongUrl(task.song, quality, true, 'local_retry'); + const result = await downloadResolver(task.song, quality, true); if (!result || !result.url) throw new Error('获取播放地址失败'); const resolvedSong = result.songInfo || task.song; @@ -851,14 +865,15 @@ class DownloadManager { // 异步重新触发云端下载 (async () => { try { - if (typeof resolveSongUrl !== 'function') throw new Error('resolveSongUrl not available'); + const downloadResolver = window.resolveDownloadSongUrl || window.resolveSongUrl; + if (typeof downloadResolver !== 'function') throw new Error('resolveSongUrl not available'); // 尝试通过 QualityManager 将可能是显示名称的 quality 转换为原始 code let quality = t.quality; if (window.QualityManager) { // getBestQuality 能处理原始 code 和 preferred 偏好,传入 t.quality 作为偏好,让其降级匹配 quality = window.QualityManager.getBestQuality(t.song, quality); } - const result = await resolveSongUrl(t.song, quality, true, 'local_retry'); + const result = await downloadResolver(t.song, quality, true); if (!result || !result.url) throw new Error('获取地址失败'); const resolvedSong = result.songInfo || t.song; From c83fde6667a317ebaf606cbe619233ee0082c9ef Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Tue, 16 Jun 2026 16:37:47 +0800 Subject: [PATCH 17/26] fix: improve artist library batch downloads --- public/music/app.js | 19 ++- public/music/js/batch_pagination.js | 6 +- public/music/js/download_manager.js | 159 +++++++++++++++--- src/modules/utils/musicSdk/tx/extendDetail.js | 2 +- src/server/server.ts | 6 +- 5 files changed, 158 insertions(+), 34 deletions(-) diff --git a/public/music/app.js b/public/music/app.js index 7234376..1906714 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -1752,6 +1752,8 @@ window.searchBySinger = searchBySinger; let currentArtistId = null; let currentArtistSource = 'wy'; let currentArtistInfo = null; +window.currentArtistId = null; +window.currentArtistSource = 'wy'; async function enterArtist(id, source = 'wy', order = 'hot', tab = 'songs', isBack = false) { const typeEl = document.getElementById('search-type'); @@ -1764,14 +1766,24 @@ async function enterArtist(id, source = 'wy', order = 'hot', tab = 'songs', isBa window.history.pushState({ page: 'search-detail' }, ''); } + const isDifferentArtist = String(currentArtistId || '') !== String(id) || currentArtistSource !== source; + if (isDifferentArtist) { + window.currentArtistSongsCache = null; + window.currentArtistAlbumsCache = null; + } + currentArtistId = id; + currentArtistSource = source; + window.currentArtistId = id; + window.currentArtistSource = source; + window.currentArtistOrder = order; window.currentArtistTab = tab; const resultsContainer = document.getElementById('search-results'); const header = document.getElementById('search-results-header'); if (header) header.classList.add('hidden'); // 只有在没有缓存或者 ID 变化时才获取详情 - if (!currentArtistInfo || currentArtistInfo.id != id) { + if (!currentArtistInfo || String(currentArtistInfo.id) !== String(id) || currentArtistInfo.source !== source) { // 如果还没有头部,显示加载 if (!document.getElementById('artist-detail-header')) { resultsContainer.innerHTML = '
'; @@ -2192,6 +2204,8 @@ window.artistSongsNextPage = artistSongsNextPage; async function loadArtistAlbums(id, source, forceFetch = false) { if (!forceFetch && window.currentArtistAlbumsCache && window.currentArtistId === id && window.currentArtistSource === source) { + window.currentArtistId = id; + window.currentArtistSource = source; renderArtistAlbumsUI(window.currentArtistAlbumsCache); return; } @@ -2204,6 +2218,7 @@ async function loadArtistAlbums(id, source, forceFetch = false) { window.currentArtistAlbumsCache = list; window.currentArtistId = id; + window.currentArtistSource = source; renderArtistAlbumsUI(list); } catch (e) { @@ -2239,7 +2254,7 @@ function renderArtistAlbumsUI(list) { ${album.name}
${album.publishTime} - ${album.total} 首 + ${album.total ?? album.count ?? album.size ?? album.songCount ?? 0} 首
`).join('')} diff --git a/public/music/js/batch_pagination.js b/public/music/js/batch_pagination.js index e6c3960..6ac9180 100644 --- a/public/music/js/batch_pagination.js +++ b/public/music/js/batch_pagination.js @@ -43,8 +43,10 @@ function refreshBatchUI() { if (window.SongListManager) window.SongListManager.renderDetail(); } else if (artistHeader) { // Artist Detail Mode - if (typeof loadArtistSongs === 'function' && window.currentArtistId) { - loadArtistSongs(window.currentArtistId, window.currentArtistOrder || 'hot'); + if (window.currentArtistSongsCache) { + renderArtistSongsUI(window.currentArtistSongsCache); + } else if (typeof loadArtistSongs === 'function' && window.currentArtistId) { + loadArtistSongs(window.currentArtistId, window.currentArtistSource || 'wy', window.currentArtistOrder || 'hot'); } } else { // Fallback to main renderResults (for search view) diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index c5638a0..a258366 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -27,6 +27,20 @@ class DownloadManager { this.restoreTasks(); } + async mapWithConcurrency(items, limit, mapper) { + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.min(limit, items.length); + const workers = Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex++; + results[index] = await mapper(items[index], index); + } + }); + await Promise.all(workers); + return results; + } + extractRawDownloadUrl(url) { if (!url || !url.startsWith('/api/music/download')) return url; try { @@ -54,6 +68,31 @@ class DownloadManager { return Math.min(5, Math.max(1, parsed)); } + getDownloadResolver() { + return window.resolveDownloadSongUrl || window.resolveSongUrl; + } + + async waitForDownloadResolver(timeoutMs = 10000) { + const resolver = this.getDownloadResolver(); + if (typeof resolver === 'function') return resolver; + + const startedAt = Date.now(); + return new Promise((resolve, reject) => { + const timer = setInterval(() => { + const currentResolver = this.getDownloadResolver(); + if (typeof currentResolver === 'function') { + clearInterval(timer); + resolve(currentResolver); + return; + } + if (Date.now() - startedAt >= timeoutMs) { + clearInterval(timer); + reject(new Error('resolveSongUrl missing')); + } + }, 50); + }); + } + shouldAutoSyncLyric(task) { return !!( task && @@ -65,6 +104,39 @@ class DownloadManager { ); } + completeServerTask(task, status = 'finished') { + task.status = status; + task.progress = 100; + task.errorMsg = ''; + task.speed = 0; + + if (this.shouldAutoSyncLyric(task)) { + window.requestServerLyricCache(task.song, task.quality).then(() => { + setTimeout(() => this.checkTaskLyric(task), 2000); + }); + } + + this.renderTask(task); + this.saveTasks(); + this.processQueue(); + } + + async refreshMissingServerTask(task) { + if (task.cacheRecheckPending) return; + task.cacheRecheckPending = true; + try { + const checker = window.checkServerCache || (typeof checkServerCache === 'function' ? checkServerCache : null); + const check = checker ? await checker(task.song, task.quality, true) : null; + if (check && check.exists && !check.isCollision) { + this.completeServerTask(task, 'finished'); + } + } catch (e) { + console.warn('[DownloadManager] Missing progress cache recheck failed:', task.id, e); + } finally { + task.cacheRecheckPending = false; + } + } + async pollServerProgress() { // [Move to top] 对已完成但还未检测过歌词的云端任务,执行检测 // 这样即使当前没有正在下载的任务,刷新页面后也能触发一次歌词状态刷新 @@ -146,6 +218,8 @@ class DownloadManager { task.totalBytes = progressInfo.total || 0; if (progressInfo.status === 'tagging' || progressInfo.status === 'finished' || progressInfo.status === 'exists') { + this.completeServerTask(task, progressInfo.status === 'exists' ? 'exists' : 'finished'); + return; if (progressInfo.status === 'tagging') task.status = 'finished'; task.progress = 100; task.errorMsg = ''; @@ -189,6 +263,8 @@ class DownloadManager { this.renderTask(task); this.saveTasks(); this.processQueue(); + } else if (task.isServer) { + this.refreshMissingServerTask(task); } } }); @@ -329,6 +405,35 @@ class DownloadManager { }; } + getSongInfoForStorage(song) { + if (!song) return {}; + return { + id: song.id, + songmid: song.songmid, + songId: song.songId, + source: song.source, + name: song.name, + singer: song.singer, + albumName: song.albumName, + albumId: song.albumId, + albumMid: song.albumMid, + interval: song.interval, + img: song.img, + types: song.types, + _types: song._types, + strMediaMid: song.strMediaMid, + hash: song.hash, + meta: song.meta ? { + songmid: song.meta.songmid, + songId: song.meta.songId, + source: song.meta.source, + picUrl: song.meta.picUrl, + singerName: song.meta.singerName, + songName: song.meta.songName + } : undefined + }; + } + // [Unified] Status generator for drawer lists getStatusHtml(icon, text, isSpin = false) { return ` @@ -343,13 +448,13 @@ class DownloadManager { async addTasks(songs) { if (!songs || songs.length === 0) return; - // Parallelize cache checks for performance - const results = await Promise.all(songs.map(async (song) => { + // Keep large batches responsive by limiting concurrent preflight requests. + const results = await this.mapWithConcurrency(songs, 8, async (song) => { const targetPref = song.quality || window.settings?.preferredQuality || '320k'; const quality = window.QualityManager ? window.QualityManager.getBestQuality(song, targetPref) : targetPref; const cacheResult = await checkServerCache(song, quality, true); return { song, quality, cacheResult }; - })); + }); let skipCount = 0; for (const { song, quality, cacheResult } of results) { @@ -396,7 +501,7 @@ class DownloadManager { window.showInfo(`${skipCount} 首歌曲已存在,已跳过`); } - // Auto open drawer if needed + // Auto open drawer; renderList only paints the first visible slice for large batches. if (this.drawer && this.drawer.classList.contains('translate-x-full')) { this.toggleDrawer(); } @@ -413,22 +518,20 @@ class DownloadManager { const serverActive = this.tasks.filter(t => t.isServer && (t.status === 'downloading' || t.status === 'tagging')).length; this.activeCount = localActive + serverActive; - if (this.activeCount >= this.maxConcurrent) { - this.renderList(); - this.updateGlobalProgress(); - return; - } - - // Find next task (could be local or server) - const nextTask = this.tasks.find(t => t.status === 'waiting'); - if (nextTask) { + while (this.activeCount < this.maxConcurrent) { + const nextTask = this.tasks.find(t => t.status === 'waiting'); + if (!nextTask) break; + if (typeof this.getDownloadResolver() !== 'function') { + setTimeout(() => this.processQueue(), 100); + break; + } + nextTask.status = 'starting'; + this.activeCount++; if (nextTask.isServer) { this.startServerDownload(nextTask); } else { this.startDownload(nextTask); } - // Recurse to fill other slots - this.processQueue(); } this.renderList(); this.updateGlobalProgress(); @@ -440,8 +543,7 @@ class DownloadManager { this.renderTask(task); try { - const downloadResolver = window.resolveDownloadSongUrl || window.resolveSongUrl; - if (typeof downloadResolver !== 'function') throw new Error('resolveSongUrl missing'); + const downloadResolver = await this.waitForDownloadResolver(); // 1. Resolve URL const quality = task.quality || (window.QualityManager ? window.QualityManager.getBestQuality(task.song, window.settings?.preferredQuality || '320k') : '320k'); @@ -525,8 +627,7 @@ class DownloadManager { if (path.includes('.')) ext = path.split('.').pop(); } catch (e) { } } else { - const downloadResolver = window.resolveDownloadSongUrl || window.resolveSongUrl; - if (typeof downloadResolver !== 'function') throw new Error('resolveSongUrl missing'); + const downloadResolver = await this.waitForDownloadResolver(); const resolveData = await downloadResolver(task.song, quality, true); if (!resolveData || !resolveData.url) throw new Error('No download URL found'); @@ -760,8 +861,7 @@ class DownloadManager { // 云端任务:恢复时由于后端进程可能已中止,需要重新触发解析与下载 (async () => { try { - const downloadResolver = window.resolveDownloadSongUrl || window.resolveSongUrl; - if (typeof downloadResolver !== 'function') throw new Error('resolveSongUrl not available'); + const downloadResolver = await this.waitForDownloadResolver(); // 重新获取音质编码(处理显示名称) let quality = task.quality; if (window.QualityManager) { @@ -865,8 +965,7 @@ class DownloadManager { // 异步重新触发云端下载 (async () => { try { - const downloadResolver = window.resolveDownloadSongUrl || window.resolveSongUrl; - if (typeof downloadResolver !== 'function') throw new Error('resolveSongUrl not available'); + const downloadResolver = await this.waitForDownloadResolver(); // 尝试通过 QualityManager 将可能是显示名称的 quality 转换为原始 code let quality = t.quality; if (window.QualityManager) { @@ -970,10 +1069,12 @@ class DownloadManager { // Persist tasks to sessionStorage saveTasks() { try { + const maxSavedTasks = 200; + const tasksToSave = this.tasks.slice(0, maxSavedTasks); // Serialize only the data we need, not the AbortController - const data = this.tasks.map(t => ({ + const data = tasksToSave.map(t => ({ id: t.id, - song: t.song, + song: this.getSongInfoForStorage(t.song), isServer: t.isServer, quality: t.quality, status: t.status === 'downloading' ? (t.isServer ? 'waiting' : 'waiting') : t.status, @@ -1215,7 +1316,13 @@ class DownloadManager { return; } - this.listContainer.innerHTML = this.tasks.map(t => this.renderTaskHtml(t)).join(''); + const renderLimit = 80; + const visibleTasks = this.tasks.slice(0, renderLimit); + const hiddenCount = this.tasks.length - visibleTasks.length; + const hiddenHtml = hiddenCount > 0 + ? `
还有 ${hiddenCount} 个任务在队列中,完成后会自动处理
` + : ''; + this.listContainer.innerHTML = visibleTasks.map(t => this.renderTaskHtml(t)).join('') + hiddenHtml; // 触发标题滚动检测 if (typeof applyMarqueeChecks === 'function') applyMarqueeChecks(); } diff --git a/src/modules/utils/musicSdk/tx/extendDetail.js b/src/modules/utils/musicSdk/tx/extendDetail.js index 06a2da0..8de758d 100644 --- a/src/modules/utils/musicSdk/tx/extendDetail.js +++ b/src/modules/utils/musicSdk/tx/extendDetail.js @@ -61,7 +61,7 @@ export default { img: item.info.img, singer: item.info.author, publishTime: item.info.publishTime, - total: item.total, + total: item.total || item.count || 0, source: 'tx', })) diff --git a/src/server/server.ts b/src/server/server.ts index 8fc1f26..b7a44ec 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -3770,15 +3770,15 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro res.writeHead(400); res.end('Missing id'); return } try { - const MAX_PAGES = 5 // 最多拉取 5 页 = 500 首 const PAGE_SIZE = 100 + const MAX_PAGES = 100 let allSongs: any[] = [] for (let p = 1; p <= MAX_PAGES; p++) { const data = await musicSdk[source].extendDetail.getArtistSongs(id, p, PAGE_SIZE, order) const pageList: any[] = data.list || [] allSongs = allSongs.concat(pageList) - // 如果本页返回数量小于 PAGE_SIZE,说明已经是最后一页 - if (pageList.length < PAGE_SIZE) break + const total = Number(data.total) || 0 + if (pageList.length < PAGE_SIZE || (total > 0 && allSongs.length >= total)) break } res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(allSongs)) From b8ed6afda63f2492c8ad81e2a7bae2fb54a56e24 Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Tue, 16 Jun 2026 21:47:29 +0800 Subject: [PATCH 18/26] fix: stabilize large server download batches --- public/music/js/download_manager.js | 158 +++++++++++++++++++--------- public/music/sw.js | 2 +- 2 files changed, 108 insertions(+), 52 deletions(-) diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index a258366..2c64f19 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -14,6 +14,10 @@ class DownloadManager { this.listContainer = document.getElementById('download-list-container'); this.globalSpeedEl = document.getElementById('download-global-speed'); this.progressTextEl = document.getElementById('download-progress-text'); + this.renderBuffer = 12; + this.estimatedTaskHeight = 92; + this.renderedRange = { start: 0, end: 0 }; + this.scrollRenderRaf = null; // Speed calculation this.lastTotalBytes = 0; @@ -23,6 +27,10 @@ class DownloadManager { // [New] Poll for server-side caching progress this.serverPollInterval = setInterval(() => this.pollServerProgress(), 2000); + if (this.listContainer) { + this.listContainer.addEventListener('scroll', () => this.scheduleScrollRender()); + } + // Restore tasks from sessionStorage this.restoreTasks(); } @@ -72,6 +80,27 @@ class DownloadManager { return window.resolveDownloadSongUrl || window.resolveSongUrl; } + normalizeServerSongId(songInfo) { + let id = String(songInfo?.songmid || songInfo?.songId || songInfo?.id || ''); + const source = songInfo?.source || 'unknown'; + if (id && !id.includes('_') && source !== 'unknown') { + id = `${source}_${id}`; + } + return id; + } + + getServerSongKey(songInfo, quality) { + return `${this.normalizeServerSongId(songInfo)}_${quality || 'unknown'}`; + } + + getTaskServerSongKey(task) { + if (!task) return ''; + if (task.serverSongKey) return task.serverSongKey; + const key = this.getServerSongKey(task.song || {}, task.quality); + if (key && !key.startsWith('_')) return key; + return task.id ? task.id.replace(/^server_(batch_)?/, '') : ''; + } + async waitForDownloadResolver(timeoutMs = 10000) { const resolver = this.getDownloadResolver(); if (typeof resolver === 'function') return resolver; @@ -146,19 +175,14 @@ class DownloadManager { }); // Poll for server tasks AND local proxy tasks - const tasksToPoll = this.tasks.filter(t => (t.isServer || (t.status === 'downloading' && !t.isServer)) && (t.status === 'waiting' || t.status === 'downloading')); + const tasksToPoll = this.tasks.filter(t => (t.isServer || (t.status === 'downloading' && !t.isServer)) && (t.status === 'waiting' || t.status === 'downloading' || t.status === 'tagging')); if (tasksToPoll.length === 0) return; // Map task IDs to names/keys the server uses const idMap = {}; tasksToPoll.forEach(t => { if (t.isServer) { - // Ensure rawId matches backend normalization: {source}_{id}_{quality} - let songId = t.song.songmid || t.song.id; - if (songId && !String(songId).includes('_') && t.song.source) { - songId = `${t.song.source}_${songId}`; - } - const rawId = `${songId}_${t.quality || 'unknown'}`; + const rawId = this.getTaskServerSongKey(t); idMap[rawId] = t.id; } else { // Local proxy download uses taskId directly @@ -212,27 +236,16 @@ class DownloadManager { return; } - task.status = (progressInfo.status === 'finished' || progressInfo.status === 'exists') ? progressInfo.status : 'downloading'; + task.status = progressInfo.status === 'tagging' + ? 'tagging' + : ((progressInfo.status === 'finished' || progressInfo.status === 'exists') ? progressInfo.status : 'downloading'); task.progress = progressInfo.progress || 0; task.downloadedBytes = progressInfo.received || 0; task.totalBytes = progressInfo.total || 0; - if (progressInfo.status === 'tagging' || progressInfo.status === 'finished' || progressInfo.status === 'exists') { + if (progressInfo.status === 'finished' || progressInfo.status === 'exists') { this.completeServerTask(task, progressInfo.status === 'exists' ? 'exists' : 'finished'); return; - if (progressInfo.status === 'tagging') task.status = 'finished'; - task.progress = 100; - task.errorMsg = ''; - // 成功完成后触发歌词同步(补充) - if (this.shouldAutoSyncLyric(task)) { - window.requestServerLyricCache(task.song, task.quality).then(() => { - // 延时一下再检查,确保后端写入完成 - setTimeout(() => this.checkTaskLyric(task), 2000); - }); - } - this.saveTasks(); - // If it just finished, free up the slot - this.processQueue(); } else { task.errorMsg = ''; } @@ -242,7 +255,7 @@ class DownloadManager { // [Fix] 处理没有进度数据的任务:key 已被删除 = 下载完成或从未开始 tasksToPoll.forEach(task => { - const rawId = task.isServer ? task.id.replace(/^server_(batch_)?/, '') : task.id; + const rawId = task.isServer ? this.getTaskServerSongKey(task) : task.id; if (data[rawId] === undefined && (task.status === 'downloading' || task.status === 'tagging')) { // 没有进度条目 + 状态是 downloading/tagging // → 如果之前进度很高或在嵌入中,说明已从内存队列移除,逻辑上视为已完成 @@ -460,7 +473,9 @@ class DownloadManager { for (const { song, quality, cacheResult } of results) { const isServerTask = song.isServer || false; if (cacheResult.exists && !cacheResult.isCollision) { - if (isServerTask) { // [Fix] 只有明确是“云端缓存”的任务才跳过已存在的 + const onlyDownloadMode = window.settings?.enableOnlyDownloadMode === true; + const targetAlreadyExists = !onlyDownloadMode || cacheResult.folder === 'music'; + if (isServerTask && targetAlreadyExists) { // 仅下载模式下 cache 命中仍需交给后端复制到 music 目录 skipCount++; continue; } @@ -470,17 +485,14 @@ class DownloadManager { // Check if already in queue (with same quality) const existing = this.tasks.find(t => t.song.id === song.id && t.quality === quality && (t.status === 'waiting' || t.status === 'downloading')); if (!existing) { - // For server tasks, FORCE a deterministic ID that matches backend track key normalization: {source}_{id}_{quality} - let rawId = song.songmid || song.id; - if (rawId && !String(rawId).includes('_') && song.source) { - rawId = `${song.source}_${rawId}`; - } - const taskId = isServerTask ? `server_${rawId}_${quality}` : (song.taskId || `dl_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`); + const serverSongKey = isServerTask ? this.getServerSongKey(song, quality) : null; + const taskId = isServerTask ? `server_${serverSongKey}` : (song.taskId || `dl_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`); this.tasks.push({ id: taskId, song: song, isServer: isServerTask, + serverSongKey, quality: quality, status: 'waiting', errorMsg: '', @@ -555,8 +567,9 @@ class DownloadManager { const resolvedSong = result.songInfo || task.song; if (resolvedSong !== task.song) { task.song = resolvedSong; - this.renderTask(task); } + task.serverSongKey = this.getServerSongKey(resolvedSong, quality); + this.renderTask(task); let rawUrl = this.extractRawDownloadUrl(result.url); if (!rawUrl.startsWith('http')) throw new Error('无法获取有效的外部下载地址'); @@ -581,6 +594,7 @@ class DownloadManager { if (!res.ok) throw new Error('服务器拒绝缓存'); // Success: pollServerProgress will now handle its movement + this.saveTasks(); console.log(`[DownloadManager] Server task started: ${task.song.name}`); } catch (e) { console.warn('[DownloadManager] Failed to start server task:', task.id, e); @@ -822,8 +836,8 @@ class DownloadManager { if (task.isServer) { // 云端任务:通知后端停止,并更新本地状态 - if (task.status === 'downloading' || task.status === 'waiting') { - const songKey = task.id.replace(/^server_(batch_)?/, ''); + if (task.status === 'downloading' || task.status === 'waiting' || task.status === 'tagging') { + const songKey = this.getTaskServerSongKey(task); const headers = { 'Content-Type': 'application/json', ...(window.getUserAuthHeaders ? window.getUserAuthHeaders() : {}) }; fetch('/api/music/cache/stop', { @@ -873,8 +887,9 @@ class DownloadManager { const resolvedSong = result.songInfo || task.song; if (resolvedSong !== task.song) { task.song = resolvedSong; - this.renderTask(task); } + task.serverSongKey = this.getServerSongKey(resolvedSong, quality); + this.renderTask(task); // [Fix] 还原代理 URL 为原始外部 URL let rawUrl = this.extractRawDownloadUrl(result.url); @@ -891,6 +906,7 @@ class DownloadManager { task.status = 'downloading'; this.renderTask(task); + this.saveTasks(); } catch (err) { console.warn('[DownloadManager] Resume cloud task failed:', task.song.name, err); task.status = 'error'; @@ -907,9 +923,9 @@ class DownloadManager { deleteTask(taskId) { const task = this.tasks.find(t => t.id === taskId); if (task) { - if (task.isServer && (task.status === 'downloading' || task.status === 'waiting')) { + if (task.isServer && (task.status === 'downloading' || task.status === 'waiting' || task.status === 'tagging')) { // 云端任务:通知后端停止 - const songKey = task.id.replace(/^server_(batch_)?/, ''); + const songKey = this.getTaskServerSongKey(task); const headers = { 'Content-Type': 'application/json', ...(window.getUserAuthHeaders ? window.getUserAuthHeaders() : {}) }; fetch('/api/music/cache/stop', { @@ -929,7 +945,7 @@ class DownloadManager { pauseAll() { this.tasks.forEach(t => { - if (t.status === 'downloading' || t.status === 'waiting') { + if (t.status === 'downloading' || t.status === 'waiting' || t.status === 'tagging') { this.pauseTask(t.id); } }); @@ -978,8 +994,9 @@ class DownloadManager { const resolvedSong = result.songInfo || t.song; if (resolvedSong !== t.song) { t.song = resolvedSong; - this.renderTask(t); } + t.serverSongKey = this.getServerSongKey(resolvedSong, quality); + this.renderTask(t); // [Fix] 还原代理 URL 为原始外部 URL let rawUrl = this.extractRawDownloadUrl(result.url); @@ -996,6 +1013,7 @@ class DownloadManager { t.status = 'downloading'; this.renderTask(t); + this.saveTasks(); } catch (err) { console.warn('[DownloadManager] Retry cloud task failed:', t.song.name, err); t.status = 'error'; @@ -1023,7 +1041,7 @@ class DownloadManager { clearAll() { // 先弹确认框 if (typeof showSelect === 'function') { - const hasActiveTasks = this.tasks.some(t => t.status === 'downloading' || t.status === 'waiting'); + const hasActiveTasks = this.tasks.some(t => t.status === 'downloading' || t.status === 'waiting' || t.status === 'tagging'); const title = hasActiveTasks ? '停止并清空任务' : '清空任务列表'; const message = hasActiveTasks ? '确认要立即停止所有进行中的任务并清空列表吗?' : '确认要清空所有下载任务记录吗?'; showSelect(title, message, { @@ -1033,7 +1051,7 @@ class DownloadManager { if (!confirmed) return; this.tasks.forEach(t => { // 本地任务调用 abort - if ((t.status === 'downloading' || t.status === 'waiting') && t.controller) { + if ((t.status === 'downloading' || t.status === 'waiting' || t.status === 'tagging') && t.controller) { try { t.controller.abort(); } catch (e) { } } }); @@ -1077,8 +1095,9 @@ class DownloadManager { song: this.getSongInfoForStorage(t.song), isServer: t.isServer, quality: t.quality, - status: t.status === 'downloading' ? (t.isServer ? 'waiting' : 'waiting') : t.status, + status: (t.status === 'downloading' || t.status === 'tagging') ? 'waiting' : t.status, progress: (t.status === 'finished' || t.status === 'exists') ? 100 : (t.isServer ? t.progress : 0), + serverSongKey: t.serverSongKey || '', errorMsg: t.errorMsg || '', retryCount: t.retryCount || 0, maxRetries: t.maxRetries || 2 @@ -1102,6 +1121,7 @@ class DownloadManager { id: t.id, song: t.song, isServer: t.isServer || false, + serverSongKey: t.serverSongKey || '', quality: t.quality || '', // Local downloading → reset to waiting to re-download; server/finished → keep status status: t.status, @@ -1133,7 +1153,7 @@ class DownloadManager { let pctCount = 0; this.tasks.forEach(t => { - if (t.status === 'downloading') { + if (t.status === 'downloading' || t.status === 'tagging') { totalSpeed += (t.speed || 0); active++; } @@ -1141,8 +1161,8 @@ class DownloadManager { if (t.status === 'finished' || t.status === 'exists') { pctTotal += 100; pctCount++; - } else if (t.status === 'downloading' || t.status === 'waiting') { - pctTotal += (t.progress || 0); + } else if (t.status === 'downloading' || t.status === 'waiting' || t.status === 'tagging') { + pctTotal += t.status === 'tagging' ? 100 : (t.progress || 0); pctCount++; } }); @@ -1199,6 +1219,10 @@ class DownloadManager { `; } + } else if (task.status === 'tagging') { + statusBg = 'bg-orange-100 text-orange-600'; + statusText = isServerTask ? '写入标签' : '处理中'; + progressWidth = 100; } else if (task.status === 'paused') { statusBg = 'bg-yellow-100 text-yellow-600'; statusText = '已暂停'; @@ -1312,26 +1336,58 @@ class DownloadManager { if (!this.listContainer) return; if (this.tasks.length === 0) { + this.renderedRange = { start: 0, end: 0 }; this.listContainer.innerHTML = this.getStatusHtml('fa-inbox', '暂无下载任务'); return; } - const renderLimit = 80; - const visibleTasks = this.tasks.slice(0, renderLimit); - const hiddenCount = this.tasks.length - visibleTasks.length; - const hiddenHtml = hiddenCount > 0 - ? `
还有 ${hiddenCount} 个任务在队列中,完成后会自动处理
` - : ''; - this.listContainer.innerHTML = visibleTasks.map(t => this.renderTaskHtml(t)).join('') + hiddenHtml; + const containerHeight = this.listContainer.clientHeight || 600; + const visibleCount = Math.ceil(containerHeight / this.estimatedTaskHeight) + this.renderBuffer * 2; + const maxStart = Math.max(0, this.tasks.length - visibleCount); + const start = Math.min( + maxStart, + Math.max(0, Math.floor(this.listContainer.scrollTop / this.estimatedTaskHeight) - this.renderBuffer) + ); + const end = Math.min(this.tasks.length, start + visibleCount); + this.renderedRange = { start, end }; + + const topSpacer = start * this.estimatedTaskHeight; + const bottomSpacer = Math.max(0, (this.tasks.length - end) * this.estimatedTaskHeight); + const visibleTasks = this.tasks.slice(start, end); + + this.listContainer.innerHTML = ` +
+ ${visibleTasks.map(t => this.renderTaskHtml(t)).join('')} +
+ `; + + const firstTaskEl = this.listContainer.querySelector('[id^="dl-task-"]'); + if (firstTaskEl) { + const measuredHeight = firstTaskEl.getBoundingClientRect().height + 8; + if (measuredHeight > 0 && Math.abs(measuredHeight - this.estimatedTaskHeight) > 6) { + this.estimatedTaskHeight = measuredHeight; + } + } + // 触发标题滚动检测 if (typeof applyMarqueeChecks === 'function') applyMarqueeChecks(); } + scheduleScrollRender() { + if (this.scrollRenderRaf) return; + this.scrollRenderRaf = requestAnimationFrame(() => { + this.scrollRenderRaf = null; + this.renderList(); + }); + } + // Update specific task in DOM to avoid full re-render renderTask(task) { if (!this.listContainer) return; const taskEl = document.getElementById(`dl-task-${task.id}`); if (!taskEl) { + const taskIndex = this.tasks.findIndex(t => t.id === task.id); + if (taskIndex >= 0 && (taskIndex < this.renderedRange.start || taskIndex >= this.renderedRange.end)) return; // Task element doesn't exist (maybe switched views?), do full render this.renderList(); return; diff --git a/public/music/sw.js b/public/music/sw.js index 45c1faf..a125b30 100644 --- a/public/music/sw.js +++ b/public/music/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'lx-music-web-v5'; +const CACHE_NAME = 'lx-music-web-v6'; const ASSETS_TO_CACHE = [ './', './index.html', From 97e76f820abe3d22412e1840b572a6e2b3f1b6be Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Tue, 23 Jun 2026 10:17:32 +0800 Subject: [PATCH 19/26] fix: improve music download recovery --- public/music/js/download_manager.js | 124 ++++++++------- public/music/js/songlist_manager.js | 13 +- public/music/sw.js | 2 +- src/modules/utils/musicSdk/tx/songList.js | 5 +- src/server/fileCache.ts | 181 ++++++++++++++++------ src/server/server.ts | 17 +- 6 files changed, 219 insertions(+), 123 deletions(-) diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index 2c64f19..e3dd869 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -158,6 +158,17 @@ class DownloadManager { const check = checker ? await checker(task.song, task.quality, true) : null; if (check && check.exists && !check.isCollision) { this.completeServerTask(task, 'finished'); + } else if (task.isServer && task.status === 'downloading' && (task.missingProgressCount || 0) >= 6) { + task.status = 'waiting'; + task.progress = 0; + task.downloadedBytes = 0; + task.totalBytes = 0; + task.speed = 0; + task.errorMsg = ''; + task.missingProgressCount = 0; + this.renderTask(task); + this.saveTasks(); + this.processQueue(); } } catch (e) { console.warn('[DownloadManager] Missing progress cache recheck failed:', task.id, e); @@ -190,12 +201,25 @@ class DownloadManager { } }); - const ids = Object.keys(idMap).join(','); + const ids = Object.keys(idMap); + const batchSize = 60; try { - const resp = await fetch(`/api/music/cache/progress?ids=${encodeURIComponent(ids)}`); - const result = await resp.json(); - if (result.success) { - const data = result.data; + const batches = []; + for (let i = 0; i < ids.length; i += batchSize) { + batches.push(ids.slice(i, i + batchSize)); + } + + const batchResults = await Promise.all(batches.map(async (batch) => { + try { + const resp = await fetch(`/api/music/cache/progress?ids=${encodeURIComponent(batch.join(','))}`); + const result = await resp.json(); + return result.success ? (result.data || {}) : {}; + } catch (e) { + console.warn('[DownloadManager] Batch progress poll failed:', e); + return {}; + } + })); + const data = Object.assign({}, ...batchResults); // 处理有进度数据的任务 Object.keys(data).forEach(rawId => { @@ -214,7 +238,9 @@ class DownloadManager { } // Calculate speed for polled tasks - if (task.lastPolledBytes !== undefined && task.lastPolledTime !== undefined) { + if (typeof progressInfo.speed === 'number') { + task.speed = Math.max(0, progressInfo.speed); + } else if (task.lastPolledBytes !== undefined && task.lastPolledTime !== undefined) { const now = Date.now(); const elapsed = (now - task.lastPolledTime) / 1000; if (elapsed > 0) { @@ -242,6 +268,7 @@ class DownloadManager { task.progress = progressInfo.progress || 0; task.downloadedBytes = progressInfo.received || 0; task.totalBytes = progressInfo.total || 0; + task.missingProgressCount = 0; if (progressInfo.status === 'finished' || progressInfo.status === 'exists') { this.completeServerTask(task, progressInfo.status === 'exists' ? 'exists' : 'finished'); @@ -265,6 +292,7 @@ class DownloadManager { task.progress = 100; task.errorMsg = ''; task.speed = 0; + task.missingProgressCount = 0; // 成功完成后触发歌词同步(补充) if (this.shouldAutoSyncLyric(task)) { @@ -277,11 +305,11 @@ class DownloadManager { this.saveTasks(); this.processQueue(); } else if (task.isServer) { + task.missingProgressCount = (task.missingProgressCount || 0) + 1; this.refreshMissingServerTask(task); } } }); - } } catch (e) { console.error('[DownloadManager] Server poll error:', e); } @@ -794,6 +822,7 @@ class DownloadManager { this.activeCount--; if (task.controller && task.controller.signal.aborted) { task.status = 'paused'; + task.speed = 0; task.errorMsg = '已暂停'; } else { console.error(`Download error for ${task.song.name}:`, error); @@ -846,8 +875,11 @@ class DownloadManager { body: JSON.stringify({ songKey }) }).catch(e => console.warn('[DownloadManager] Failed to stop server task:', e)); task.status = 'paused'; + task.speed = 0; task.errorMsg = '已暂停'; this.renderTask(task); + this.saveTasks(); + this.updateGlobalProgress(); } } else { // 本地任务 @@ -857,7 +889,10 @@ class DownloadManager { } } else if (task.status === 'waiting') { task.status = 'paused'; + task.speed = 0; this.renderTask(task); + this.saveTasks(); + this.updateGlobalProgress(); } } } @@ -868,56 +903,18 @@ class DownloadManager { task.status = 'waiting'; task.downloadedBytes = 0; + task.totalBytes = 0; task.progress = 0; + task.speed = 0; + task.errorMsg = ''; + task.missingProgressCount = 0; + task.cacheRecheckPending = false; + task.lastPolledBytes = undefined; + task.lastPolledTime = undefined; + task.controller = null; this.renderTask(task); - - if (task.isServer) { - // 云端任务:恢复时由于后端进程可能已中止,需要重新触发解析与下载 - (async () => { - try { - const downloadResolver = await this.waitForDownloadResolver(); - // 重新获取音质编码(处理显示名称) - let quality = task.quality; - if (window.QualityManager) { - quality = window.QualityManager.getBestQuality(task.song, quality); - } - const result = await downloadResolver(task.song, quality, true); - if (!result || !result.url) throw new Error('获取播放地址失败'); - - const resolvedSong = result.songInfo || task.song; - if (resolvedSong !== task.song) { - task.song = resolvedSong; - } - task.serverSongKey = this.getServerSongKey(resolvedSong, quality); - this.renderTask(task); - - // [Fix] 还原代理 URL 为原始外部 URL - let rawUrl = this.extractRawDownloadUrl(result.url); - if (!rawUrl.startsWith('http')) throw new Error('无法获取有效的外部下载地址'); - - const headers = { 'Content-Type': 'application/json', ...(window.getUserAuthHeaders ? window.getUserAuthHeaders() : {}) }; - - const res = await fetch('/api/music/cache/download', { - method: 'POST', - headers, - body: JSON.stringify({ songInfo: this.getSongInfoForServer(resolvedSong), url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, namingPattern: window.settings?.serverCacheNamingPattern || 'simple', cacheLyric: window.settings?.enableServerLyricCache !== false, embedLyric: !!(window.settings?.embedLyricToFile ?? true) }) - }); - if (!res.ok) throw new Error('服务器拒绝请求'); - - task.status = 'downloading'; - this.renderTask(task); - this.saveTasks(); - } catch (err) { - console.warn('[DownloadManager] Resume cloud task failed:', task.song.name, err); - task.status = 'error'; - task.errorMsg = err.message || '恢复失败'; - this.renderTask(task); - } - })(); - } else { - // 本地任务 - this.processQueue(); - } + this.saveTasks(); + this.processQueue(); } deleteTask(taskId) { @@ -1087,16 +1084,17 @@ class DownloadManager { // Persist tasks to sessionStorage saveTasks() { try { - const maxSavedTasks = 200; - const tasksToSave = this.tasks.slice(0, maxSavedTasks); // Serialize only the data we need, not the AbortController - const data = tasksToSave.map(t => ({ + const data = this.tasks.map(t => ({ id: t.id, song: this.getSongInfoForStorage(t.song), isServer: t.isServer, quality: t.quality, - status: (t.status === 'downloading' || t.status === 'tagging') ? 'waiting' : t.status, + status: t.isServer ? t.status : ((t.status === 'downloading' || t.status === 'tagging') ? 'waiting' : t.status), progress: (t.status === 'finished' || t.status === 'exists') ? 100 : (t.isServer ? t.progress : 0), + downloadedBytes: t.downloadedBytes || 0, + totalBytes: t.totalBytes || 0, + speed: t.speed || 0, serverSongKey: t.serverSongKey || '', errorMsg: t.errorMsg || '', retryCount: t.retryCount || 0, @@ -1126,9 +1124,9 @@ class DownloadManager { // Local downloading → reset to waiting to re-download; server/finished → keep status status: t.status, progress: t.progress || 0, - downloadedBytes: 0, - totalBytes: 0, - speed: 0, + downloadedBytes: t.downloadedBytes || 0, + totalBytes: t.totalBytes || 0, + speed: t.speed || 0, errorMsg: t.errorMsg || '', retryCount: t.retryCount || 0, maxRetries: t.maxRetries || 2, @@ -1210,7 +1208,7 @@ class DownloadManager { statusText = isServerTask ? (hasRealProgress ? `云端 ${progressWidth}%` : '云端下载中') : `${progressWidth}%`; - speedText = task.speed > 0 ? `${this.formatSize(task.speed)}/s` : ''; + speedText = `${this.formatSize(Math.max(0, task.speed || 0))}/s`; if (!isServerTask) { actionBtnHTML = ` diff --git a/public/music/js/songlist_manager.js b/public/music/js/songlist_manager.js index 3af2e03..dac4c2f 100644 --- a/public/music/js/songlist_manager.js +++ b/public/music/js/songlist_manager.js @@ -30,7 +30,7 @@ window.SongListManager = (function () { }; // Initialize - function init() { + async function init() { console.log('[SongList] Initializing...'); // 优先从缓存读取 @@ -42,7 +42,7 @@ window.SongListManager = (function () { } renderSortTabs(); - loadTags(); + await loadTags(); loadList(); // Bind events that might not be in HTML attributes @@ -155,6 +155,9 @@ window.SongListManager = (function () { currentState.tags = data.tags || []; currentState.hotTags = data.hotTags || []; currentState.sortList = data.sortList || []; + if (currentState.sortList.length > 0 && !currentState.sortList.some(opt => String(opt.id) === String(currentState.sortId))) { + currentState.sortId = currentState.sortList[0].id; + } renderSortTabs(); renderTags(); } catch (e) { @@ -552,7 +555,7 @@ window.SongListManager = (function () { toggleTagSelector(false); loadList(1); }, - changeSource: function () { + changeSource: async function () { currentState.source = document.getElementById('songlist-source').value; // 保存到缓存 @@ -563,7 +566,7 @@ window.SongListManager = (function () { document.getElementById('current-tag-name').innerText = '全部分类'; currentState.tags = []; renderSortTabs(); - loadTags(); + await loadTags(); loadList(1); }, changeSort: function (sort) { @@ -815,5 +818,3 @@ function toggleSlDetailHeader() { icon.style.transform = 'rotate(180deg)'; } } - - diff --git a/public/music/sw.js b/public/music/sw.js index a125b30..79623a6 100644 --- a/public/music/sw.js +++ b/public/music/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'lx-music-web-v6'; +const CACHE_NAME = 'lx-music-web-v9'; const ASSETS_TO_CACHE = [ './', './index.html', diff --git a/src/modules/utils/musicSdk/tx/songList.js b/src/modules/utils/musicSdk/tx/songList.js index 0a793dd..fb2b2d4 100644 --- a/src/modules/utils/musicSdk/tx/songList.js +++ b/src/modules/utils/musicSdk/tx/songList.js @@ -31,6 +31,7 @@ export default { tagsUrl: 'https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=%7B%22tags%22%3A%7B%22method%22%3A%22get_all_categories%22%2C%22param%22%3A%7B%22qq%22%3A%22%22%7D%2C%22module%22%3A%22playlist.PlaylistAllCategoriesServer%22%7D%7D', hotTagUrl: 'https://c.y.qq.com/node/pc/wk_v15/category_playlist.html', getListUrl(sortId, id, page) { + const order = Number(sortId) || 5 if (id) { id = parseInt(id) return `https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=${encodeURIComponent(JSON.stringify({ @@ -44,6 +45,8 @@ export default { size: this.limit_list, page: page - 1, use_page: 1, + order, + sort: order, }, module: 'playlist.PlayListCategoryServer', }, @@ -53,7 +56,7 @@ export default { comm: { cv: 1602, ct: 20 }, playlist: { method: 'get_playlist_by_tag', - param: { id: 10000000, sin: this.limit_list * (page - 1), size: this.limit_list, order: sortId, cur_page: page }, + param: { id: 10000000, sin: this.limit_list * (page - 1), size: this.limit_list, order, cur_page: page }, module: 'playlist.PlayListPlazaServer', }, }))}` diff --git a/src/server/fileCache.ts b/src/server/fileCache.ts index 15fd9da..a1d3889 100644 --- a/src/server/fileCache.ts +++ b/src/server/fileCache.ts @@ -1,6 +1,7 @@ import fs from 'fs' +import type { Stats } from 'fs' import path from 'path' import http from 'http' import https from 'https' @@ -36,7 +37,7 @@ let currentCacheLocation = CACHE_ROOTS.ROOT // Helper to get actual directory path // [Unified Enhancement] Cache Progress Tracker -export const cacheProgress: Map = new Map() +export const cacheProgress: Map = new Map() // [New] Active Cache Tasks Tracker: username -> [ { songKey, controller } ] export const activeTasks: Map> = new Map() @@ -103,6 +104,9 @@ interface CacheItem { hasCover?: boolean hasLyric?: boolean hasEmbedLyric?: boolean + coverCheckedVersion?: number + coverCheckedMtime?: number + coverCheckedSize?: number bitrate?: number sampleRate?: number bitDepth?: number @@ -221,6 +225,50 @@ class CacheIndexManager { export const indexManager = new CacheIndexManager() +const COVER_CHECK_VERSION = 2 + +const getCoverCacheHash = (filename: string, stats?: Stats) => { + const version = stats ? `${stats.size}:${stats.mtimeMs}` : '' + return crypto.createHash('md5').update(`${filename}:${version}`).digest('hex') +} + +const resolveCacheRelativePath = (dir: string, filename: string) => { + const root = path.resolve(dir) + const resolved = path.resolve(root, filename) + if (resolved !== root && !resolved.startsWith(root + path.sep)) { + return null + } + return resolved +} + +const hasValidPictureData = (picture: any) => { + if (!picture || !picture.data) return false + if (typeof picture.data.length === 'number') return picture.data.length > 0 + if (typeof picture.data.byteLength === 'number') return picture.data.byteLength > 0 + return false +} + +const hasValidEmbeddedCover = (pictures: any) => { + return Array.isArray(pictures) && pictures.some(hasValidPictureData) +} + +const isPlaceholderCoverUrl = (url: any) => { + return typeof url === 'string' && /\/T002R\d+x\d+M000\.jpg(?:$|\?)/.test(url) +} + +const readEmbeddedCoverState = (filePath: string) => { + let tagger: any + try { + tagger = new MusicTagger() + tagger.loadPath(filePath) + return hasValidEmbeddedCover(tagger.pictures) + } catch (e) { + return false + } finally { + try { if (tagger) tagger.dispose() } catch (e) { } + } +} + // Ensure directory exists const ensureDir = (username?: string, isOnlyDownload?: boolean) => { const dir = getCacheDir(username, isOnlyDownload) @@ -464,8 +512,14 @@ export const syncCacheIndex = async (username?: string) => { let finalQuality = quality || 'unknown' - // Update or add to index if anything changed (size, mtime, or lyric status) - if (!existing || existing.size !== stats.size || existing.hasLyric !== hasLyricOnDisk || !existing.interval || existing.quality === 'unknown' || !existing.bitrate) { + const needsCoverCheck = !existing || + existing.coverCheckedVersion !== COVER_CHECK_VERSION || + existing.coverCheckedMtime !== stats.mtimeMs || + existing.coverCheckedSize !== stats.size || + existing.hasCover === undefined + + // Update or add to index if anything changed (size, mtime, lyric status, or cover status) + if (!existing || existing.size !== stats.size || existing.hasLyric !== hasLyricOnDisk || needsCoverCheck || !existing.interval || existing.quality === 'unknown' || !existing.bitrate) { if (existing) { existing.size = stats.size existing.mtime = stats.mtimeMs @@ -477,6 +531,15 @@ export const syncCacheIndex = async (username?: string) => { updated = true } + if (needsCoverCheck) { + const actualHasCover = readEmbeddedCoverState(filePath) + if (existing.hasCover !== actualHasCover) updated = true + existing.hasCover = actualHasCover + existing.coverCheckedVersion = COVER_CHECK_VERSION + existing.coverCheckedMtime = stats.mtimeMs + existing.coverCheckedSize = stats.size + } + // If interval or quality/bitrate is missing/unknown, or hasEmbedLyric not yet detected, try to extract it if (!existing.interval || existing.quality === 'unknown' || !existing.bitrate || existing.hasEmbedLyric === undefined) { try { @@ -514,7 +577,7 @@ export const syncCacheIndex = async (username?: string) => { if (tagger.title && !songName) songName = tagger.title if (tagger.artist && !singer) singer = tagger.artist if (tagger.album && !album) album = tagger.album - if (tagger.pictures && tagger.pictures.length > 0) hasCover = true + if (hasValidEmbeddedCover(tagger.pictures)) hasCover = true const dur = tagger.duration interval = dur ? formatPlayTime(dur / 1000) : '' @@ -552,6 +615,9 @@ export const syncCacheIndex = async (username?: string) => { hasCover: hasCover, hasLyric: hasLyricOnDisk, hasEmbedLyric, + coverCheckedVersion: COVER_CHECK_VERSION, + coverCheckedMtime: stats.mtimeMs, + coverCheckedSize: stats.size, bitrate: bitrate, sampleRate: sampleRate, bitDepth: bitDepth @@ -596,15 +662,15 @@ export const syncCacheIndex = async (username?: string) => { export const getCacheList = async (username?: string) => { const normalizedUsername = (username && username !== '_open' && username !== 'default') ? username : '_open' - // Automatically trigger sync if index files don't exist + // Keep indexed metadata aligned with disk. This also repairs stale hasCover values + // from older indexes where the cover endpoint may already return 404. const cacheDir = getCacheDir(normalizedUsername, false) const musicDir = getCacheDir(normalizedUsername, true) const hasCacheIndex = fs.existsSync(path.join(cacheDir, 'cache_index.json')) const hasMusicIndex = fs.existsSync(path.join(musicDir, 'music_index.json')) - if (!hasCacheIndex || !hasMusicIndex) { - await syncCacheIndex(username) - } + if (!hasCacheIndex || !hasMusicIndex) await syncCacheIndex(username) + else await syncCacheIndex(normalizedUsername) const cacheItems = indexManager.getAll(normalizedUsername, 'cache') const musicItems = indexManager.getAll(normalizedUsername, 'music') @@ -741,7 +807,7 @@ export const batchUpdateMetadata = async (filenames: string[], username: string try { let imageBuffer: Buffer | undefined const imageUrl = item.img - if (imageUrl && imageUrl.startsWith('http')) { + if (imageUrl && imageUrl.startsWith('http') && !isPlaceholderCoverUrl(imageUrl)) { const chunks: Buffer[] = [] const p = imageUrl.startsWith('https') ? https : http imageBuffer = await new Promise((resolveI, rejectI) => { @@ -764,7 +830,7 @@ export const batchUpdateMetadata = async (filenames: string[], username: string if (imageBuffer && imageBuffer.length > 0) { tagger.pictures = [new MetaPicture('image/jpeg', new Uint8Array(imageBuffer), 'Cover')] item.hasCover = true - } else if (tagger.pictures && tagger.pictures.length > 0) { + } else if (hasValidEmbeddedCover(tagger.pictures)) { item.hasCover = true } else { item.hasCover = false @@ -875,41 +941,46 @@ export const linkLocalFile = async (oldFilename: string, songInfo: any, username export const getCacheCover = (filename: string, username?: string) => { const normalizedUsername = (username && username !== '_open' && username !== 'default') ? username : '_open' - // Cover cache lookup - try { - const hash = crypto.createHash('md5').update(filename).digest('hex') - const coverCacheDir = getCoverCacheDir(normalizedUsername) - const binPath = path.join(coverCacheDir, `${hash}.bin`) - const mimePath = path.join(coverCacheDir, `${hash}.mime`) - - if (fs.existsSync(binPath) && fs.existsSync(mimePath)) { - const data = fs.readFileSync(binPath) - const mime = fs.readFileSync(mimePath, 'utf8') - return { data, mime } - } - } catch (e) { - console.error(`[Cache] Error reading cover cache for: ${filename}`, e) - } - const roots: Array<'cache' | 'music'> = ['cache', 'music'] for (const folder of roots) { const dir = getCacheDir(normalizedUsername, folder === 'music') - const filePath = path.join(dir, filename) // [Fix] Allow subfolders + const filePath = resolveCacheRelativePath(dir, filename) // [Fix] Allow subfolders safely + + if (filePath && fs.existsSync(filePath)) { + let stats: Stats | undefined + try { + stats = fs.statSync(filePath) + const hash = getCoverCacheHash(filename, stats) + const coverCacheDir = getCoverCacheDir(normalizedUsername) + const binPath = path.join(coverCacheDir, `${hash}.bin`) + const mimePath = path.join(coverCacheDir, `${hash}.mime`) + + if (fs.existsSync(binPath) && fs.existsSync(mimePath)) { + const data = fs.readFileSync(binPath) + const mime = fs.readFileSync(mimePath, 'utf8') + if (data.length > 0) return { data, mime } + try { + fs.unlinkSync(binPath) + fs.unlinkSync(mimePath) + } catch (e) { } + } + } catch (e) { + console.error(`[Cache] Error reading cover cache for: ${filename}`, e) + } - if (fs.existsSync(filePath)) { try { const tagger = new MusicTagger() tagger.loadPath(filePath) const pics = tagger.pictures - if (pics && pics.length > 0) { - const pic = pics[0] + const pic = Array.isArray(pics) ? pics.find(hasValidPictureData) : null + if (pic) { const mime = pic.mimeType || 'image/jpeg' const data = Buffer.from(pic.data) tagger.dispose() // Save to cover cache try { - const hash = crypto.createHash('md5').update(filename).digest('hex') + const hash = getCoverCacheHash(filename, stats) const coverCacheDir = getCoverCacheDir(normalizedUsername) const binPath = path.join(coverCacheDir, `${hash}.bin`) const mimePath = path.join(coverCacheDir, `${hash}.mime`) @@ -940,9 +1011,14 @@ export const removeCacheFile = (filename: string, username?: string) => { for (const folder of roots) { const dir = getCacheDir(normalizedUsername, folder === 'music') - const filePath = path.join(dir, path.basename(filename)) + const filePath = resolveCacheRelativePath(dir, filename) + + if (filePath && fs.existsSync(filePath)) { + let coverCacheHash = '' + try { + coverCacheHash = getCoverCacheHash(filename, fs.statSync(filePath)) + } catch (e) { } - if (fs.existsSync(filePath)) { fs.unlinkSync(filePath) console.log(`[FileCache] Deleted from ${folder}: ${filename}`) @@ -967,12 +1043,14 @@ export const removeCacheFile = (filename: string, username?: string) => { // [New] Delete associated cover cache if exists try { - const hash = crypto.createHash('md5').update(filename).digest('hex') const coverCacheDir = getCoverCacheDir(normalizedUsername) - const binPath = path.join(coverCacheDir, `${hash}.bin`) - const mimePath = path.join(coverCacheDir, `${hash}.mime`) - if (fs.existsSync(binPath)) fs.unlinkSync(binPath) - if (fs.existsSync(mimePath)) fs.unlinkSync(mimePath) + const hashes = [coverCacheHash, crypto.createHash('md5').update(filename).digest('hex')].filter(Boolean) + for (const hash of hashes) { + const binPath = path.join(coverCacheDir, `${hash}.bin`) + const mimePath = path.join(coverCacheDir, `${hash}.mime`) + if (fs.existsSync(binPath)) fs.unlinkSync(binPath) + if (fs.existsSync(mimePath)) fs.unlinkSync(mimePath) + } } catch (e) {} deleted = true @@ -1287,7 +1365,7 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str try { const tagger = new MusicTagger() tagger.loadPath(finalPath) - hasCover = !!(tagger.pictures && tagger.pictures.length > 0) + hasCover = hasValidEmbeddedCover(tagger.pictures) const lyricsInTag = tagger.lyrics hasEmbedLyric = !!(lyricsInTag && lyricsInTag.trim().length > 10) tagger.dispose() @@ -1364,9 +1442,12 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str return } - cacheProgress.set(songKey, { progress: 0, status: 'downloading' }) + cacheProgress.set(songKey, { progress: 0, status: 'downloading', total: 0, received: 0, speed: 0, updatedAt: Date.now() }) const total = parseInt(res.headers['content-length'] || '0', 10) let received = 0 + let lastSpeedAt = Date.now() + let lastSpeedBytes = 0 + let currentSpeed = 0 const contentType = res.headers['content-type'] || '' let headerExt = '.mp3' if (contentType.includes('audio/flac')) headerExt = '.flac' @@ -1377,16 +1458,20 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str const fileStream = fs.createWriteStream(tempPath) res.on('data', (chunk) => { received += chunk.length - if (total > 0) { - const progress = Math.round((received / total) * 100) - cacheProgress.set(songKey, { progress, status: 'downloading', total, received }) + const now = Date.now() + if (now - lastSpeedAt >= 1000) { + currentSpeed = Math.max(0, (received - lastSpeedBytes) / ((now - lastSpeedAt) / 1000)) + lastSpeedAt = now + lastSpeedBytes = received } + const progress = total > 0 ? Math.round((received / total) * 100) : 0 + cacheProgress.set(songKey, { progress, status: 'downloading', total, received, speed: currentSpeed, updatedAt: now }) }) res.pipe(fileStream) fileStream.on('close', async () => { if (settled) return - cacheProgress.set(songKey, { progress: 100, status: 'tagging' }) + cacheProgress.set(songKey, { progress: 100, status: 'tagging', total, received, speed: 0, updatedAt: Date.now() }) let ext = headerExt if (fs.existsSync(tempPath)) { @@ -1408,7 +1493,7 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str let imageBuffer: Buffer | undefined try { const imageUrl = songInfo.img || (songInfo.meta && songInfo.meta.picUrl) - if (imageUrl && imageUrl.startsWith('http')) { + if (imageUrl && imageUrl.startsWith('http') && !isPlaceholderCoverUrl(imageUrl)) { const chunks: Buffer[] = [] const p = imageUrl.startsWith('https') ? https : http imageBuffer = await new Promise((resolveI, rejectI) => { @@ -1432,7 +1517,7 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str interval: metadata.interval, source: metadata.source, quality: quality || 'unknown', filename: baseName + ext, folder: folderType, mtime: Date.now(), size: received, - ext: ext.replace('.', ''), hasCover: !!(imageBuffer), hasLyric: false + ext: ext.replace('.', ''), hasCover: !!(imageBuffer && imageBuffer.length > 0), hasLyric: false }, folderType) try { @@ -1441,7 +1526,7 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str tagger.title = metadata.name tagger.artist = metadata.singer tagger.album = metadata.album - if (imageBuffer) tagger.pictures = [new MetaPicture('image/jpeg', new Uint8Array(imageBuffer), 'Cover')] + if (imageBuffer && imageBuffer.length > 0) tagger.pictures = [new MetaPicture('image/jpeg', new Uint8Array(imageBuffer), 'Cover')] tagger.save() tagger.dispose() } catch (e) { } @@ -1471,7 +1556,7 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str } catch (e) { /* Lyric cache/embed failure must not fail the audio cache. */ } } - cacheProgress.set(songKey, { progress: 100, status: 'finished' }) + cacheProgress.set(songKey, { progress: 100, status: 'finished', total: total || received, received, speed: 0, updatedAt: Date.now() }) setTimeout(() => cacheProgress.delete(songKey), 30000) settle(() => { resolve(); void checkAndCleanupCache(username) }) }) diff --git a/src/server/server.ts b/src/server/server.ts index b7a44ec..5374ad6 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -3223,22 +3223,31 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro const chunks: any[] = [] let received = 0 const total = parseInt(proxyRes.headers['content-length'] as string || '0', 10) + let lastSpeedAt = Date.now() + let lastSpeedBytes = 0 + let currentSpeed = 0 if (taskId) { - fileCache.cacheProgress.set(taskId, { progress: 0, status: 'downloading', total, received: 0 }) + fileCache.cacheProgress.set(taskId, { progress: 0, status: 'downloading', total, received: 0, speed: 0, updatedAt: Date.now() }) } proxyRes.on('data', (c: any) => { chunks.push(c) if (taskId) { received += c.length + const now = Date.now() + if (now - lastSpeedAt >= 1000) { + currentSpeed = Math.max(0, (received - lastSpeedBytes) / ((now - lastSpeedAt) / 1000)) + lastSpeedAt = now + lastSpeedBytes = received + } const progress = total > 0 ? Math.round((received / total) * 100) : 0 - fileCache.cacheProgress.set(taskId, { progress, status: 'downloading', total, received }) + fileCache.cacheProgress.set(taskId, { progress, status: 'downloading', total, received, speed: currentSpeed, updatedAt: now }) } }) proxyRes.on('end', async () => { if (taskId) { - fileCache.cacheProgress.set(taskId, { progress: 100, status: 'tagging', total, received: total }) + fileCache.cacheProgress.set(taskId, { progress: 100, status: 'tagging', total, received, speed: 0, updatedAt: Date.now() }) } try { const buffer = Buffer.concat(chunks) @@ -3301,7 +3310,7 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro tagger.dispose() if (taskId) { - fileCache.cacheProgress.set(taskId, { progress: 100, status: 'finished', total, received: total }) + fileCache.cacheProgress.set(taskId, { progress: 100, status: 'finished', total: total || received, received, speed: 0, updatedAt: Date.now() }) setTimeout(() => fileCache.cacheProgress.delete(taskId), 30000) } From 97ee489b3d04932e4d4ccfe9596f6569e569853e Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Wed, 24 Jun 2026 21:58:14 +0800 Subject: [PATCH 20/26] Optimize large music list rendering --- public/music/app.js | 158 ++++++++------------------------- public/music/index.html | 12 +++ public/music/js/local_music.js | 62 ++++++++++++- src/server/fileCache.ts | 23 ++++- 4 files changed, 131 insertions(+), 124 deletions(-) diff --git a/public/music/app.js b/public/music/app.js index 1906714..4102277 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -726,16 +726,17 @@ function changeQualityPreference(quality) { // Tab Switching function switchTab(tabId) { + // Favorites is a sidebar group toggle, not a main content view. + if (tabId === 'favorites') { + handleFavoritesClick(); + return; + } + document.querySelectorAll('[id^="view-"]').forEach(el => { el.classList.add('hidden'); el.classList.remove('opacity-100'); el.classList.add('opacity-0'); }); - // special handling for favorites - if (tabId === 'favorites' && currentListData) { - handleFavoritesClick(); - return; - } const activeView = document.getElementById(`view-${tabId}`); if (!activeView) return; @@ -2583,8 +2584,8 @@ function renderResults(list) { updatePaginationInfo(startIndex + 1, endIndex, totalItems, currentPage, totalPages); // Init Lazy Loader - lazyLoadImages(); - applyMarqueeChecks(); + lazyLoadImages(container); + applyMarqueeChecks(container); // [Prefetch] 自动后台预加载逻辑 if (currentSearchScope === 'network' && currentPage === totalPages) { @@ -2614,10 +2615,11 @@ function createMarqueeHtml(text, className = '') { return `
${text}
`; } //滚动显示 -function applyMarqueeChecks() { +function applyMarqueeChecks(root = document) { // Wait for render setTimeout(() => { - const elements = document.querySelectorAll('.dynamic-marquee.truncate'); + const scope = root || document; + const elements = scope.querySelectorAll('.dynamic-marquee.truncate'); elements.forEach(el => { if (el.scrollWidth > el.clientWidth) { const text = el.getAttribute('data-text') || el.innerText; @@ -2650,78 +2652,51 @@ window.addEventListener('resize', () => { // Lazy Loading Logic let imageObserver; -function lazyLoadImages() { - // 禁用 lazyload 逻辑,直接遍历所有图片并快速加载 - const imagesToLoad = document.querySelectorAll('img.lazy-image'); - imagesToLoad.forEach(img => { +function lazyLoadImages(root = document) { + const scope = root || document; + const loadImage = (img) => { const src = img.getAttribute('data-src'); - if (src) { - if (img.src.includes('logo.svg')) { - img.classList.add('is-placeholder'); - } - img.src = src; - img.onload = () => { - img.classList.remove('is-placeholder', 'opacity-0'); - img.removeAttribute('data-src'); - }; - img.onerror = () => { - img.src = '/music/assets/logo.svg'; - img.classList.add('is-placeholder'); - }; - } - }); - - return; // 短路返回,保留并禁用以下原有的交集观察者懒加载逻辑 + if (!src) return; + if (img.src.includes('logo.svg')) { + img.classList.add('is-placeholder'); + } + img.src = src; + img.onload = () => { + img.classList.remove('is-placeholder', 'opacity-0'); + img.removeAttribute('data-src'); + }; + img.onerror = () => { + img.src = '/music/assets/logo.svg'; + img.classList.add('is-placeholder'); + }; + }; - // If IntersectionObserver is supported if ('IntersectionObserver' in window) { - if (imageObserver) { - imageObserver.disconnect(); - } - - imageObserver = new IntersectionObserver((entries, observer) => { + if (!imageObserver) { + imageObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { - const img = entry.target; - const src = img.getAttribute('data-src'); - - if (src) { - // If we are about to switch from placeholder, ensure is-placeholder class is present - if (img.src.includes('logo.svg')) { - img.classList.add('is-placeholder'); - } - - img.src = src; - img.onload = () => { - img.classList.remove('is-placeholder', 'opacity-0'); - img.removeAttribute('data-src'); - }; - img.onerror = () => { - img.src = '/music/assets/logo.svg'; - img.classList.add('is-placeholder'); - }; - } - observer.unobserve(img); + loadImage(entry.target); + observer.unobserve(entry.target); } }); }, { rootMargin: '100px 0px', // Load before it comes into view threshold: 0.01 }); + } - const images = document.querySelectorAll('img.lazy-image'); + const images = scope.querySelectorAll('img.lazy-image[data-src]'); images.forEach(img => { imageObserver.observe(img); }); } else { // Fallback for older browsers - const images = document.querySelectorAll('img.lazy-image'); - images.forEach(img => { - const src = img.getAttribute('data-src'); - if (src) img.src = src; - }); + const images = scope.querySelectorAll('img.lazy-image[data-src]'); + images.forEach(loadImage); } } +window.lazyLoadImages = lazyLoadImages; // List search logic is now handled by ListSearch service @@ -8641,23 +8616,6 @@ function handleListClick(listId, skipAutoUpdate = false) { function handleFavoritesClick() { exitListSecondaryModes(); - toggleFavorites(); // Toggle folder dropdown in sidebar - - // [New] 为全局收藏视图初始化搜索状态 - initGlobalListSearch(); - - if (!currentListData) { - // Not logged in, switch to the guidance view directly - switchTab('favorites'); - document.getElementById('page-title').innerText = "我的收藏"; - return; - } - - // Switch to Search View (Global Local) - document.querySelectorAll('[id^="view-"]').forEach(el => el.classList.add('hidden')); - const activeView = document.getElementById('view-search'); - activeView.classList.remove('hidden'); - setTimeout(() => activeView.classList.remove('opacity-0'), 10); // Simple fade // Highlight Header document.querySelectorAll('[id^="tab-"]').forEach(el => { @@ -8670,47 +8628,7 @@ function handleFavoritesClick() { favTab.classList.remove('t-text-muted'); } - // Always clear sub-item highlight when switching to "All Favorites" - document.querySelectorAll('[data-sidebar-list-id]').forEach(el => { - el.classList.remove('active-sub-item'); - el.classList.add('t-text-muted'); - }); - - // UI Updates - document.getElementById('page-title').innerText = "我的收藏 (全部)"; - document.getElementById('search-input').value = ''; - document.getElementById('search-input').placeholder = "搜索所有收藏..."; - document.getElementById('search-source').classList.add('hidden'); - document.getElementById('search-type').classList.add('hidden'); - - // Set Scope - currentSearchScope = 'local_all'; - - // Collect all songs from Default, Love, and User Lists - let allSongs = []; - if (currentListData) { - if (currentListData.defaultList) allSongs = allSongs.concat(currentListData.defaultList); - if (currentListData.loveList) allSongs = allSongs.concat(currentListData.loveList); - if (currentListData.userList) { - currentListData.userList.forEach(l => { - if (l.list) allSongs = allSongs.concat(l.list); - }); - } - } - - // Deduplicate by song ID - const uniqueSongs = []; - const seenIds = new Set(); - allSongs.forEach(s => { - if (s && s.id && !seenIds.has(s.id)) { - seenIds.add(s.id); - uniqueSongs.push(s); - } - }); - - // Update render - currentPage = 1; - renderResults(uniqueSongs); + toggleFavorites(); } async function handleCreateList() { diff --git a/public/music/index.html b/public/music/index.html index 17d3093..b53531e 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -1299,6 +1299,18 @@

本地

正在加载本地音乐...

+ diff --git a/public/music/js/local_music.js b/public/music/js/local_music.js index 3cf83b8..bf233d5 100644 --- a/public/music/js/local_music.js +++ b/public/music/js/local_music.js @@ -6,6 +6,8 @@ window.LocalMusicManager = { originalData: [], displayData: [], + currentPage: 1, + pageSize: 60, batchMode: false, selectedItems: new Set(), searchKeyword: '', @@ -319,6 +321,7 @@ window.LocalMusicManager = { applyFilters() { let current = this.originalData; + this.currentPage = 1; // 2. Read current filter values from input const searchInput = document.getElementById('lm-search-input'); @@ -473,11 +476,60 @@ window.LocalMusicManager = { this.render(); }, + getTotalPages() { + return Math.max(1, Math.ceil((this.displayData.length || 0) / this.pageSize)); + }, + + getPageSlice() { + const totalPages = this.getTotalPages(); + if (this.currentPage > totalPages) this.currentPage = totalPages; + if (this.currentPage < 1) this.currentPage = 1; + const start = (this.currentPage - 1) * this.pageSize; + const end = Math.min(start + this.pageSize, this.displayData.length); + return { + start, + end, + list: this.displayData.slice(start, end), + totalPages + }; + }, + + changePage(delta) { + const totalPages = this.getTotalPages(); + const nextPage = Math.min(totalPages, Math.max(1, this.currentPage + delta)); + if (nextPage === this.currentPage) return; + this.currentPage = nextPage; + this.render(); + const container = document.getElementById('lm-list-container'); + if (container) container.scrollTop = 0; + }, + + updatePagination() { + const pagination = document.getElementById('lm-pagination'); + const info = document.getElementById('lm-page-info'); + const prev = document.getElementById('lm-page-prev'); + const next = document.getElementById('lm-page-next'); + if (!pagination) return; + + const total = this.displayData.length; + const totalPages = this.getTotalPages(); + if (total <= this.pageSize) { + pagination.classList.add('hidden'); + } else { + pagination.classList.remove('hidden'); + } + + if (info) info.textContent = `第 ${this.currentPage} / ${totalPages} 页 (${total} 首)`; + if (prev) prev.disabled = this.currentPage <= 1; + if (next) next.disabled = this.currentPage >= totalPages; + }, + render() { const container = document.getElementById('lm-list-container'); if (!container) return; if (this.displayData.length === 0) { + this.updatePagination(); container.innerHTML = `
@@ -487,9 +539,12 @@ window.LocalMusicManager = { } const username = (window.currentListData && window.currentListData.username) || localStorage.getItem('lx_sync_user') || '_open'; + const page = this.getPageSlice(); + this.updatePagination(); let html = ''; - this.displayData.forEach((item, index) => { + page.list.forEach((item, pageIndex) => { + const index = page.start + pageIndex; const isUnindexed = item.source === 'unknown' || (item.songmid && item.songmid.includes(' - ')); const isNoTag = (n) => !n || n === '未知歌曲' || n === '未知歌手' || n.toLowerCase() === 'unknown'; const missingID3 = isNoTag(item.name) || isNoTag(item.singer) || isUnindexed; @@ -506,7 +561,7 @@ window.LocalMusicManager = { if (item.hasCover) { const authToken = (window.getUserAuthHeaders ? window.getUserAuthHeaders()['x-user-token'] : null) || localStorage.getItem('lx_user_token') || ''; const coverUrl = `/api/music/cache/cover?filename=${encodeURIComponent(item.filename)}&user=${encodeURIComponent(username)}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`; - coverHtml = ``; + coverHtml = ``; } const formatSize = (bytes) => { @@ -639,6 +694,9 @@ window.LocalMusicManager = { }); container.innerHTML = html; + if (typeof window.lazyLoadImages === 'function') { + window.lazyLoadImages(container); + } }, toggleSelect(filename, checked) { diff --git a/src/server/fileCache.ts b/src/server/fileCache.ts index a1d3889..be94d74 100644 --- a/src/server/fileCache.ts +++ b/src/server/fileCache.ts @@ -34,6 +34,8 @@ export const CACHE_ROOTS = { } let currentCacheLocation = CACHE_ROOTS.ROOT +const CACHE_LIST_SYNC_TTL = 30 * 1000 +const cacheListSyncState: Map }> = new Map() // Helper to get actual directory path // [Unified Enhancement] Cache Progress Tracker @@ -654,6 +656,11 @@ export const syncCacheIndex = async (username?: string) => { indexManager.save(normalizedUsername, folder) } } + + const syncKey = `${currentCacheLocation}:${normalizedUsername}` + const syncState = cacheListSyncState.get(syncKey) || { lastSync: 0 } + syncState.lastSync = Date.now() + cacheListSyncState.set(syncKey, syncState) } /** @@ -669,8 +676,20 @@ export const getCacheList = async (username?: string) => { const hasCacheIndex = fs.existsSync(path.join(cacheDir, 'cache_index.json')) const hasMusicIndex = fs.existsSync(path.join(musicDir, 'music_index.json')) - if (!hasCacheIndex || !hasMusicIndex) await syncCacheIndex(username) - else await syncCacheIndex(normalizedUsername) + const syncKey = `${currentCacheLocation}:${normalizedUsername}` + const syncState = cacheListSyncState.get(syncKey) || { lastSync: 0 } + const mustSync = !hasCacheIndex || !hasMusicIndex + const shouldSync = mustSync || Date.now() - syncState.lastSync > CACHE_LIST_SYNC_TTL + + if (shouldSync) { + if (!syncState.pending) { + syncState.pending = syncCacheIndex(normalizedUsername) + .then(() => { syncState.lastSync = Date.now() }) + .finally(() => { syncState.pending = undefined }) + cacheListSyncState.set(syncKey, syncState) + } + await syncState.pending + } const cacheItems = indexManager.getAll(normalizedUsername, 'cache') const musicItems = indexManager.getAll(normalizedUsername, 'music') From 9f05fbf16dfdfc81852faa6af0e6ff72c72d7194 Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Mon, 29 Jun 2026 20:07:37 +0800 Subject: [PATCH 21/26] fix: address pr review feedback --- public/music/app.js | 79 +++++++++++++++++++++-------- public/music/js/download_manager.js | 77 ++++++++-------------------- public/music/js/local_music.js | 14 ++++- public/music/js/single_song_ops.js | 14 +++-- public/music/js/songlist_manager.js | 2 + src/server/fileCache.ts | 26 +++++++++- src/server/server.ts | 25 ++++++--- 7 files changed, 148 insertions(+), 89 deletions(-) diff --git a/public/music/app.js b/public/music/app.js index 4102277..501ed53 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -125,6 +125,20 @@ const DEFAULT_SETTINGS = { deduplicatePlaylistByQuality: true, // 同 ID 歌曲仅加入最高音质 (默认开启) }; +function normalizeDownloadConcurrency(value) { + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed)) return DEFAULT_SETTINGS.downloadConcurrency; + return Math.min(5, Math.max(1, parsed)); +} + +function normalizeStoredSettings(nextSettings) { + if (!nextSettings || typeof nextSettings !== 'object') return nextSettings; + if (nextSettings.downloadConcurrency !== undefined) { + nextSettings.downloadConcurrency = normalizeDownloadConcurrency(nextSettings.downloadConcurrency); + } + return nextSettings; +} + let settings = { ...DEFAULT_SETTINGS }; // 歌词原始数据,用于设置切换时重新渲染 @@ -140,7 +154,7 @@ let currentRecoveryState = null; // 播放失败自动恢复状态管理 try { const saved = localStorage.getItem('lx_settings'); if (saved) { - settings = { ...settings, ...JSON.parse(saved) }; + settings = normalizeStoredSettings({ ...settings, ...JSON.parse(saved) }); } } catch (e) { console.error('[Settings] 加载设置失败:', e); @@ -2612,7 +2626,8 @@ function createMarqueeHtml(text, className = '') { // Return a container marked for dynamic checking // different screens are different, so we check overflow after render // Added min-w-0 to prevent flex item from expanding beyond parent - return `
${text}
`; + const safeText = escapeHtmlText(text); + return `
${safeText}
`; } //滚动显示 function applyMarqueeChecks(root = document) { @@ -2623,7 +2638,6 @@ function applyMarqueeChecks(root = document) { elements.forEach(el => { if (el.scrollWidth > el.clientWidth) { const text = el.getAttribute('data-text') || el.innerText; - const gap = ''; // 增加间距 // 必须保留 overflow-hidden 以限制宽度 el.classList.remove('truncate'); @@ -2632,12 +2646,22 @@ function applyMarqueeChecks(root = document) { // 使用 mask-image 实现边缘渐隐效果 const maskStyle = 'mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%); -webkit-mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);'; - el.innerHTML = ` -
-
- ${text}${gap}${text}${gap} -
-
`; + const wrapper = document.createElement('div'); + wrapper.className = 'w-full relative'; + wrapper.setAttribute('style', maskStyle); + const track = document.createElement('div'); + track.className = 'inline-block whitespace-nowrap animate-marquee hover:pause-animation'; + const firstText = document.createElement('span'); + firstText.textContent = text; + const firstGap = document.createElement('span'); + firstGap.className = 'mx-8'; + const secondText = document.createElement('span'); + secondText.textContent = text; + const secondGap = document.createElement('span'); + secondGap.className = 'mx-8'; + track.append(firstText, firstGap, secondText, secondGap); + wrapper.appendChild(track); + el.replaceChildren(wrapper); } }); }, 50); @@ -2668,6 +2692,7 @@ function lazyLoadImages(root = document) { img.onerror = () => { img.src = '/music/assets/logo.svg'; img.classList.add('is-placeholder'); + img.removeAttribute('data-src'); }; }; @@ -2697,6 +2722,11 @@ function lazyLoadImages(root = document) { } } window.lazyLoadImages = lazyLoadImages; +window.unobserveLazyImages = function (root = document) { + if (!imageObserver) return; + const scope = root || document; + scope.querySelectorAll('img.lazy-image').forEach(img => imageObserver.unobserve(img)); +}; // List search logic is now handled by ListSearch service @@ -2870,7 +2900,8 @@ async function resolveDownloadSongUrl(song, quality, isSilent = true) { : (preferredQuality || '320k'); while (candidateQuality) { - const key = `${candidateSong.source || ''}_${candidateSong.id || candidateSong.songmid || ''}_${candidateQuality}`; + const candidateId = candidateSong.id || candidateSong.songmid || candidateSong.songId || candidateSong.hash || candidateSong.copyrightId || candidateSong.mid || candidateSong.mediaMid || `${candidateSong.name || ''}_${candidateSong.singer || ''}_${candidateSong.interval || ''}`; + const key = `${candidateSong.source || ''}_${candidateId}_${candidateQuality}`; if (tried.has(key)) break; tried.add(key); @@ -2922,7 +2953,8 @@ function normalizeSongMatchText(value) { function isSingerMatch(sourceSinger, targetSinger) { const sourceText = normalizeSongMatchText(sourceSinger); const targetText = normalizeSongMatchText(targetSinger); - if (!sourceText || !targetText) return true; + if (!targetText) return true; + if (!sourceText) return false; if (sourceText.includes(targetText) || targetText.includes(sourceText)) return true; const splitSinger = value => String(value || '') @@ -3955,7 +3987,7 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr if (currentSourceType !== 'normal') { const retryHandler = () => { console.warn(`[Player] ${currentSourceType} link failed, retrying online...`); - if (currentSourceType === 'cache') localStorage.removeItem(`lx_url_${cleanSongData(playbackSong).id}_${targetQuality}`); + if (currentSourceType === 'cache') localStorage.removeItem(`lx_url_${cleanSongData(playbackSong).id}_${currentQuality || targetQuality}`); playSong(playbackSong, index, targetQuality, noPlay, currentSourceType === 'server_cache' ? 'local_retry' : true); }; audio.addEventListener('error', retryHandler, { once: true }); @@ -5167,7 +5199,7 @@ function loadSettings() { const saved = localStorage.getItem('lx_settings'); if (saved) { const loaded = JSON.parse(saved); - settings = { ...settings, ...loaded }; + settings = normalizeStoredSettings({ ...settings, ...loaded }); console.log('[Settings] 加载设置成功:', settings); } } catch (e) { @@ -5304,6 +5336,9 @@ document.addEventListener('keyup', (e) => { }); async function updateSetting(key, value) { + if (SETTINGS_UI_MAP[key]?.normalize) { + value = SETTINGS_UI_MAP[key].normalize(value); + } const restrictedKeys = ['enableServerCache', 'enableServerLyricCache', 'serverCacheLocation', 'serverCacheNamingPattern', 'enableOnlyDownloadMode']; const isPublic = !currentListData?.username || currentListData?.username === 'default'; const enablePublicRestriction = window.lx_config?.['user.enablePublicRestriction']; @@ -5411,11 +5446,7 @@ const SETTINGS_UI_MAP = { downloadConcurrency: { id: 'setting-download-concurrency', type: 'value', - normalize: (v) => { - const parsed = parseInt(v, 10); - if (!Number.isFinite(parsed)) return DEFAULT_SETTINGS.downloadConcurrency; - return Math.min(5, Math.max(1, parsed)); - }, + normalize: normalizeDownloadConcurrency, action: (v) => { if (window.SystemDownloadManager) { window.SystemDownloadManager.updateMaxConcurrent(v); @@ -5571,6 +5602,10 @@ function syncSettingsUI(key = null, value = null) { if (!config) return; if (config.normalize) itemValue = config.normalize(itemValue); + if (settings[itemKey] !== itemValue) { + settings[itemKey] = itemValue; + window.settings = settings; + } const el = document.getElementById(config.id); if (el) { if (config.type === 'checkbox') el.checked = !!itemValue; @@ -6008,7 +6043,8 @@ async function retryCacheLyric(btn, item) { albumName: item.album || '' }; - await window.requestServerLyricCache(songData, item.quality, true); // 强制补齐 + const synced = await window.requestServerLyricCache(songData, item.quality, true); // 强制补齐 + if (!synced) throw new Error('No lyric data available'); showSuccess(`已成功补齐歌词: ${item.name}`); // 成功后给予反馈并刷新列表 @@ -6048,7 +6084,8 @@ async function downloadAllCacheLyrics() { albumName: item.album || '' }; if (window.requestServerLyricCache) { - await window.requestServerLyricCache(songData, item.quality, true); // 强制补全 + const synced = await window.requestServerLyricCache(songData, item.quality, true); // 强制补全 + if (!synced) throw new Error('No lyric data available'); } // 每首之间稍作停顿 await new Promise(r => setTimeout(r, 500)); @@ -7872,7 +7909,7 @@ async function fetchSettingsFromServer() { const serverSettings = await res.json(); console.log('[Settings] 从服务器加载设置成功:', serverSettings); // Merge settings - settings = { ...settings, ...serverSettings }; + settings = normalizeStoredSettings({ ...settings, ...serverSettings }); // Save to local localStorage.setItem('lx_settings', JSON.stringify(settings)); // Update UI diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index e3dd869..c2364ea 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -50,9 +50,11 @@ class DownloadManager { } extractRawDownloadUrl(url) { - if (!url || !url.startsWith('/api/music/download')) return url; + if (!url) return url; try { - const proxyParams = new URLSearchParams(url.includes('?') ? url.split('?')[1] : ''); + const parsedUrl = new URL(url, window.location.origin); + if (parsedUrl.origin !== window.location.origin || parsedUrl.pathname !== '/api/music/download') return url; + const proxyParams = parsedUrl.searchParams; const extracted = proxyParams.get('url'); if (!extracted) return url; if (extracted.startsWith('http')) return extracted; @@ -101,6 +103,12 @@ class DownloadManager { return task.id ? task.id.replace(/^server_(batch_)?/, '') : ''; } + createTaskId(prefix = 'dl') { + const cryptoObj = window.crypto || window.msCrypto; + if (cryptoObj?.randomUUID) return `${prefix}_${cryptoObj.randomUUID()}`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + } + async waitForDownloadResolver(timeoutMs = 10000) { const resolver = this.getDownloadResolver(); if (typeof resolver === 'function') return resolver; @@ -140,8 +148,8 @@ class DownloadManager { task.speed = 0; if (this.shouldAutoSyncLyric(task)) { - window.requestServerLyricCache(task.song, task.quality).then(() => { - setTimeout(() => this.checkTaskLyric(task), 2000); + window.requestServerLyricCache(task.song, task.quality).then((synced) => { + if (synced) setTimeout(() => this.checkTaskLyric(task), 2000); }); } @@ -296,8 +304,8 @@ class DownloadManager { // 成功完成后触发歌词同步(补充) if (this.shouldAutoSyncLyric(task)) { - window.requestServerLyricCache(task.song, task.quality).then(() => { - setTimeout(() => this.checkTaskLyric(task), 2000); + window.requestServerLyricCache(task.song, task.quality).then((synced) => { + if (synced) setTimeout(() => this.checkTaskLyric(task), 2000); }); } @@ -370,7 +378,8 @@ class DownloadManager { this.renderTask(task); try { - await window.requestServerLyricCache(task.song, task.quality, true); // 强制补全 + const synced = await window.requestServerLyricCache(task.song, task.quality, true); // 强制补全 + if (!synced) throw new Error('No lyric data available'); if (window.showSuccess) window.showSuccess(`已成功补全歌词: ${task.song.name}`); // 再次检查 setTimeout(() => this.checkTaskLyric(task), 1500); @@ -514,7 +523,7 @@ class DownloadManager { const existing = this.tasks.find(t => t.song.id === song.id && t.quality === quality && (t.status === 'waiting' || t.status === 'downloading')); if (!existing) { const serverSongKey = isServerTask ? this.getServerSongKey(song, quality) : null; - const taskId = isServerTask ? `server_${serverSongKey}` : (song.taskId || `dl_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`); + const taskId = song.taskId || this.createTaskId(isServerTask ? 'server' : 'dl'); this.tasks.push({ id: taskId, @@ -880,6 +889,7 @@ class DownloadManager { this.renderTask(task); this.saveTasks(); this.updateGlobalProgress(); + this.processQueue(); } } else { // 本地任务 @@ -970,54 +980,10 @@ class DownloadManager { this.tasks = this.tasks.filter(x => x.id !== t.id); if (t.isServer) { - // 云端任务:重新 resolve URL 并触发后端下载 + // 云端任务:放回队列等待 processQueue 调度 t.status = 'waiting'; this.tasks.push(t); this.renderTask(t); - - // 异步重新触发云端下载 - (async () => { - try { - const downloadResolver = await this.waitForDownloadResolver(); - // 尝试通过 QualityManager 将可能是显示名称的 quality 转换为原始 code - let quality = t.quality; - if (window.QualityManager) { - // getBestQuality 能处理原始 code 和 preferred 偏好,传入 t.quality 作为偏好,让其降级匹配 - quality = window.QualityManager.getBestQuality(t.song, quality); - } - const result = await downloadResolver(t.song, quality, true); - if (!result || !result.url) throw new Error('获取地址失败'); - - const resolvedSong = result.songInfo || t.song; - if (resolvedSong !== t.song) { - t.song = resolvedSong; - } - t.serverSongKey = this.getServerSongKey(resolvedSong, quality); - this.renderTask(t); - - // [Fix] 还原代理 URL 为原始外部 URL - let rawUrl = this.extractRawDownloadUrl(result.url); - if (!rawUrl.startsWith('http')) throw new Error('无法获取有效的外部下载地址'); - - const headers = { 'Content-Type': 'application/json', ...(window.getUserAuthHeaders ? window.getUserAuthHeaders() : {}) }; - - const res = await fetch('/api/music/cache/download', { - method: 'POST', - headers, - body: JSON.stringify({ songInfo: this.getSongInfoForServer(resolvedSong), url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, namingPattern: window.settings?.serverCacheNamingPattern || 'simple', cacheLyric: window.settings?.enableServerLyricCache !== false, embedLyric: !!(window.settings?.embedLyricToFile ?? true) }) - }); - if (!res.ok) throw new Error('服务器拒绝缓存'); - - t.status = 'downloading'; - this.renderTask(t); - this.saveTasks(); - } catch (err) { - console.warn('[DownloadManager] Retry cloud task failed:', t.song.name, err); - t.status = 'error'; - t.errorMsg = err.message || '重试失败'; - this.renderTask(t); - } - })(); } else { // 本地任务:放回队列等待 processQueue 调度 t.status = 'waiting'; @@ -1059,7 +1025,8 @@ class DownloadManager { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-user-name': username + 'x-user-name': username, + ...(window.getUserAuthHeaders ? window.getUserAuthHeaders() : {}) }, body: JSON.stringify({ all: true }) }).catch(err => console.error('[DownloadManager] Failed to stop server tasks:', err)); @@ -1116,7 +1083,7 @@ class DownloadManager { data.forEach(t => { this.tasks.push({ - id: t.id, + id: /^[A-Za-z0-9_-]+$/.test(String(t.id || '')) ? t.id : this.createTaskId(t.isServer ? 'server' : 'dl'), song: t.song, isServer: t.isServer || false, serverSongKey: t.serverSongKey || '', diff --git a/public/music/js/local_music.js b/public/music/js/local_music.js index bf233d5..4b2b1b4 100644 --- a/public/music/js/local_music.js +++ b/public/music/js/local_music.js @@ -513,6 +513,8 @@ window.LocalMusicManager = { const total = this.displayData.length; const totalPages = this.getTotalPages(); + if (this.currentPage > totalPages) this.currentPage = totalPages; + if (this.currentPage < 1) this.currentPage = 1; if (total <= this.pageSize) { pagination.classList.add('hidden'); } else { @@ -530,6 +532,9 @@ window.LocalMusicManager = { if (this.displayData.length === 0) { this.updatePagination(); + if (typeof window.unobserveLazyImages === 'function') { + window.unobserveLazyImages(container); + } container.innerHTML = `
@@ -693,6 +698,9 @@ window.LocalMusicManager = { `; }); + if (typeof window.unobserveLazyImages === 'function') { + window.unobserveLazyImages(container); + } container.innerHTML = html; if (typeof window.lazyLoadImages === 'function') { window.lazyLoadImages(container); @@ -899,7 +907,11 @@ window.LocalMusicManager = { try { // If single_song_ops exposes requestServerLyricCache if (typeof window.requestServerLyricCache === 'function') { - await window.requestServerLyricCache(item.songInfo, item.quality, true); + const synced = await window.requestServerLyricCache(item.songInfo, item.quality, true); + if (!synced) { + fail++; + continue; + } success++; if (typeof showInfo === 'function') showInfo(`[${success}/${targets.length}] 成功补全: ${item.name}`); } else { diff --git a/public/music/js/single_song_ops.js b/public/music/js/single_song_ops.js index 05a6120..464aa45 100644 --- a/public/music/js/single_song_ops.js +++ b/public/music/js/single_song_ops.js @@ -99,7 +99,7 @@ async function deleteSingleSong(songId) { * @param {Boolean} force 是否强制同步(忽略设置开关,用于手动点击按钮) */ async function requestServerLyricCache(song, quality = null, force = false) { - if (!force && (typeof settings === 'undefined' || settings.enableServerLyricCache === false)) return; + if (!force && (typeof settings === 'undefined' || settings.enableServerLyricCache === false)) return false; console.log(`[Lyric] 尝试同步下载歌词缓存: ${song.name} (${quality || 'auto'})`); try { @@ -115,16 +115,16 @@ async function requestServerLyricCache(song, quality = null, force = false) { if (!source || !songmid) { console.warn('[Lyric] 歌曲缺少必要字段,跳过歌词缓存同步:', song); - return; + return false; } // 1. 先尝试获取歌词数据 const lyricUrl = `/api/music/lyric?source=${source}&songmid=${songmid}&name=${name}&singer=${singer}&hash=${hash}&interval=${interval}`; const lRes = await fetch(lyricUrl); - if (!lRes.ok) return; + if (!lRes.ok) return false; const lyricInfo = await lRes.json(); - if (!lyricInfo || (!lyricInfo.lyric && !lyricInfo.lrc)) return; + if (!lyricInfo || (!lyricInfo.lyric && !lyricInfo.lrc)) return false; // 2. 将歌词推送到服务器缓存接口 const cacheUrl = `/api/music/cache/lyric`; @@ -148,7 +148,7 @@ async function requestServerLyricCache(song, quality = null, force = false) { const enableOnlyDownloadMode = window.settings?.enableOnlyDownloadMode || false; - await fetch(cacheUrl, { + const cacheRes = await fetch(cacheUrl, { method: 'POST', headers, body: JSON.stringify({ @@ -157,9 +157,13 @@ async function requestServerLyricCache(song, quality = null, force = false) { enableOnlyDownloadMode }) }); + if (!cacheRes.ok) throw new Error('Lyric cache request failed'); console.log(`[Lyric] 歌曲下载触发的歌词缓存同步成功: ${song.name} (仅下载模式: ${enableOnlyDownloadMode})`); + return true; } catch (e) { console.warn(`[Lyric] 自动同步歌词缓存失败: ${song.name}`, e); + if (force) throw e; + return false; } } diff --git a/public/music/js/songlist_manager.js b/public/music/js/songlist_manager.js index dac4c2f..7d0fea4 100644 --- a/public/music/js/songlist_manager.js +++ b/public/music/js/songlist_manager.js @@ -565,6 +565,8 @@ window.SongListManager = (function () { currentState.tagName = '全部分类'; document.getElementById('current-tag-name').innerText = '全部分类'; currentState.tags = []; + currentState.sortList = []; + currentState.sortId = ''; renderSortTabs(); await loadTags(); loadList(1); diff --git a/src/server/fileCache.ts b/src/server/fileCache.ts index be94d74..ad180b9 100644 --- a/src/server/fileCache.ts +++ b/src/server/fileCache.ts @@ -1475,6 +1475,7 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str else if (contentType.includes('audio/wav')) headerExt = '.wav' const fileStream = fs.createWriteStream(tempPath) + let writeFinished = false res.on('data', (chunk) => { received += chunk.length const now = Date.now() @@ -1488,8 +1489,19 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str }) res.pipe(fileStream) + fileStream.on('finish', () => { writeFinished = true }) fileStream.on('close', async () => { if (settled) return + if (!writeFinished) { + fs.unlink(tempPath, () => { }) + fail(new Error('Download stream closed before write finished')) + return + } + if (total > 0 && received < total) { + fs.unlink(tempPath, () => { }) + fail(new Error(`Download incomplete: ${received}/${total}`)) + return + } cacheProgress.set(songKey, { progress: 100, status: 'tagging', total, received, speed: 0, updatedAt: Date.now() }) let ext = headerExt @@ -1516,11 +1528,20 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str const chunks: Buffer[] = [] const p = imageUrl.startsWith('https') ? https : http imageBuffer = await new Promise((resolveI, rejectI) => { - p.get(imageUrl, ires => { + const imgReq = p.get(imageUrl, ires => { + if (ires.statusCode && ires.statusCode >= 400) { + ires.resume() + rejectI(new Error(`Cover status: ${ires.statusCode}`)) + return + } ires.on('data', c => chunks.push(c)) ires.on('end', () => resolveI(Buffer.concat(chunks))) ires.on('error', rejectI) }) + imgReq.on('error', rejectI) + imgReq.setTimeout(10000, () => { + imgReq.destroy(new Error('Cover download timeout')) + }) }) } } catch (e) { } @@ -1583,6 +1604,9 @@ export const downloadAndCache = async (songInfo: any, url: string, quality?: str fileStream.on('error', (err) => { fs.unlink(tempPath, () => { }); fail(err) }) }) req.on('error', (err) => { fs.unlink(tempPath, () => { }); fail(err) }) + req.setTimeout(30000, () => { + req.destroy(new Error('Download request timeout')) + }) }) } diff --git a/src/server/server.ts b/src/server/server.ts index 5374ad6..476113c 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -2351,6 +2351,14 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro username = verified } if (namingPattern) { + if (isPublic && global.lx.config['user.enablePublicRestriction']) { + const auth = req.headers['x-frontend-auth'] + if (auth !== global.lx.config['frontend.password']) { + res.writeHead(403, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: 'Unauthorized to change cache naming pattern' })) + return + } + } fileCache.setNamingPattern(namingPattern) if (global.lx.config) global.lx.config['cache.namingPattern'] = namingPattern } @@ -3249,6 +3257,11 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro if (taskId) { fileCache.cacheProgress.set(taskId, { progress: 100, status: 'tagging', total, received, speed: 0, updatedAt: Date.now() }) } + const finishProgress = () => { + if (!taskId) return + fileCache.cacheProgress.set(taskId, { progress: 100, status: 'finished', total: total || received, received, speed: 0, updatedAt: Date.now() }) + setTimeout(() => fileCache.cacheProgress.delete(taskId), 30000) + } try { const buffer = Buffer.concat(chunks) if (buffer.length < 100) throw new Error('File too small, possibly invalid'); @@ -3309,11 +3322,6 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro console.log('[DownloadProxy] Metadata saved successfully for:', songName) tagger.dispose() - if (taskId) { - fileCache.cacheProgress.set(taskId, { progress: 100, status: 'finished', total: total || received, received, speed: 0, updatedAt: Date.now() }) - setTimeout(() => fileCache.cacheProgress.delete(taskId), 30000) - } - const tagged = fs.readFileSync(tempPath) fs.unlink(tempPath, () => { }) headers['Content-Length'] = tagged.length.toString() @@ -3321,11 +3329,13 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro res.writeHead(200, headers) res.end(tagged) } + finishProgress() } catch (e: any) { if (!res.headersSent) { res.writeHead(200, headers) res.end(Buffer.concat(chunks)) } + finishProgress() } }) return @@ -3780,7 +3790,10 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro } try { const PAGE_SIZE = 100 - const MAX_PAGES = 100 + const configuredMaxPages = Number((global.lx.config as any)?.['artist.maxFetchPages']) + const MAX_PAGES = Number.isFinite(configuredMaxPages) && configuredMaxPages > 0 + ? Math.min(Math.floor(configuredMaxPages), 100) + : 20 let allSongs: any[] = [] for (let p = 1; p <= MAX_PAGES; p++) { const data = await musicSdk[source].extendDetail.getArtistSongs(id, p, PAGE_SIZE, order) From 2676726d248af23d28d14234e3f217e1968118c6 Mon Sep 17 00:00:00 2001 From: bobcc4 Date: Mon, 29 Jun 2026 20:18:31 +0800 Subject: [PATCH 22/26] fix: address follow-up review feedback --- public/music/js/download_manager.js | 21 ++++--- public/music/js/local_music.js | 92 +++++++++++++++++++++++------ src/server/server.ts | 6 +- 3 files changed, 89 insertions(+), 30 deletions(-) diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index c2364ea..f04926c 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -613,19 +613,22 @@ class DownloadManager { // 2. Post to backend const headers = { 'Content-Type': 'application/json', ...(window.getUserAuthHeaders ? window.getUserAuthHeaders() : {}) }; + const payload = { + songInfo: this.getSongInfoForServer(resolvedSong), + url: rawUrl, + quality, + enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, + cacheLyric: window.settings?.enableServerLyricCache !== false, + embedLyric: !!(window.settings?.embedLyricToFile ?? true) + }; + if (window.settings?.serverCacheNamingPattern && headers['x-frontend-auth']) { + payload.namingPattern = window.settings.serverCacheNamingPattern; + } const res = await fetch('/api/music/cache/download', { method: 'POST', headers, - body: JSON.stringify({ - songInfo: this.getSongInfoForServer(resolvedSong), - url: rawUrl, - quality, - enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, - namingPattern: window.settings?.serverCacheNamingPattern || 'simple', - cacheLyric: window.settings?.enableServerLyricCache !== false, - embedLyric: !!(window.settings?.embedLyricToFile ?? true) - }) + body: JSON.stringify(payload) }); if (!res.ok) throw new Error('服务器拒绝缓存'); diff --git a/public/music/js/local_music.js b/public/music/js/local_music.js index 4b2b1b4..8119121 100644 --- a/public/music/js/local_music.js +++ b/public/music/js/local_music.js @@ -28,6 +28,52 @@ window.LocalMusicManager = { subPathModalMode: 'filter', // [New] 'filter' | 'categorize' cacheKey: 'lx_lm_filters', // [New] localStorage key enableReMapping: false, + listEventsBound: false, + + escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, ch => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + })[ch]); + }, + + escapeAttr(value) { + return this.escapeHtml(value); + }, + + bindListEvents() { + if (this.listEventsBound) return; + const container = document.getElementById('lm-list-container'); + if (!container) return; + this.listEventsBound = true; + container.addEventListener('click', (event) => { + const target = event.target.closest('[data-lm-action]'); + if (!target || !container.contains(target)) return; + const index = parseInt(target.dataset.lmIndex || '', 10); + switch (target.dataset.lmAction) { + case 'play': + this.playItem(index); + break; + case 'download': + this.downloadSingle(index); + break; + case 'delete': + this.deleteSingle(target.dataset.lmFilename || ''); + break; + case 'manual': + this.openManualIndexModal(index); + break; + } + }); + container.addEventListener('change', (event) => { + const target = event.target; + if (!target.matches('[data-lm-action="select"]')) return; + this.toggleSelect(target.dataset.lmFilename || '', target.checked); + }); + }, saveFilters() { const filters = { @@ -144,6 +190,7 @@ window.LocalMusicManager = { // Try reading global cache location to sync the selector. this.syncLocationSelector(); this.loadFilters(); // Load cached filters + this.bindListEvents(); this.fetchData(); // Listen to tab switch to trigger refresh if we are on this tab @@ -529,6 +576,7 @@ window.LocalMusicManager = { render() { const container = document.getElementById('lm-list-container'); if (!container) return; + this.bindListEvents(); if (this.displayData.length === 0) { this.updatePagination(); @@ -550,6 +598,12 @@ window.LocalMusicManager = { let html = ''; page.list.forEach((item, pageIndex) => { const index = page.start + pageIndex; + const safeFilename = this.escapeAttr(item.filename || ''); + const safeName = this.escapeHtml(item.name || '未知歌曲'); + const safeSinger = this.escapeHtml(item.singer || '未知歌手'); + const safeAlbum = this.escapeHtml(item.album || '--'); + const safeSource = this.escapeHtml(item.source === 'unknown' ? '未知' : (item.source || '')); + const safeSubPath = this.escapeHtml(item.subPath || ''); const isUnindexed = item.source === 'unknown' || (item.songmid && item.songmid.includes(' - ')); const isNoTag = (n) => !n || n === '未知歌曲' || n === '未知歌手' || n.toLowerCase() === 'unknown'; const missingID3 = isNoTag(item.name) || isNoTag(item.singer) || isUnindexed; @@ -566,7 +620,7 @@ window.LocalMusicManager = { if (item.hasCover) { const authToken = (window.getUserAuthHeaders ? window.getUserAuthHeaders()['x-user-token'] : null) || localStorage.getItem('lx_user_token') || ''; const coverUrl = `/api/music/cache/cover?filename=${encodeURIComponent(item.filename)}&user=${encodeURIComponent(username)}${authToken ? `&token=${encodeURIComponent(authToken)}` : ''}`; - coverHtml = ``; + coverHtml = ``; } const formatSize = (bytes) => { @@ -583,13 +637,13 @@ window.LocalMusicManager = { const folderIcon = item.folder === 'music' ? '' : ''; html += ` -
+
${index + 1}
@@ -599,12 +653,12 @@ window.LocalMusicManager = {
${coverHtml}
-
- ${item.name || '未知歌曲'} +
+ ${safeName}
- ${item.singer || '未知歌手'} - ${qualityName || '标准'} + ${safeSinger} + ${this.escapeHtml(qualityName || '标准')} ${item.bitrate ? `` : ''} ${item.sampleRate ? `` : ''} ${item.bitDepth && item.bitDepth > 16 ? `` : ''} @@ -615,10 +669,10 @@ window.LocalMusicManager = {
${folderIcon} - ${item.source === 'unknown' ? '未知' : item.source} + ${safeSource}
- ${item.subPath ? `${item.subPath}` : ''} + ${item.subPath ? `${safeSubPath}` : ''}
${missingID3 ? '缺标签' : ''} @@ -628,7 +682,7 @@ window.LocalMusicManager = {
${(isUnindexed || this.enableReMapping) ? ` - @@ -644,21 +698,21 @@ window.LocalMusicManager = {