diff --git a/.gitignore b/.gitignore index 273ed13..e430177 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/public/music/app.js b/public/music/app.js index e742287..e16d325 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -73,15 +73,15 @@ const DEFAULT_SETTINGS = { enableAutoProxy: true, // 自动代理 enableCustomProxy: false, // 是否启用自定义代理 customProxyUrl: '', // 自定义代理URL模板,使用 {url} 作为原始URL占位符 - enableOnlyDownloadMode: false, // 仅下载模式 - downloadConcurrency: 3, // 缓存并发量 (1-6) + enableOnlyDownloadMode: true, // 仅下载模式 + downloadConcurrency: 3, // 缓存并发量 (1-5) hotSearchLimit: 20, // 热搜显示数量 lyricFontSize: 1.25, // 歌词字体大小 (rem) lyricFontFamily: '', // 词字体 switchPlaylistOnSearchPlay: true, // 播放搜索歌曲时切换歌单 (默认开启) switchPlaylistOnSongListPlay: true, // 播放歌单/排行榜歌曲时切换歌单 (默认开启) autoResume: true, // 自动恢复进度 (默认开启) - showSidebarSongInfo: true, // 展示侧边栏封面 + showSidebarSongInfo: false, // 展示侧边栏封面 enableCrossfade: true, // 音频淡入淡出 keepScreenAwake: true, // 保持屏幕唤醒设置 enableKeyboardShortcuts: true, // 按键快捷方式 (默认开启) @@ -116,6 +116,8 @@ const DEFAULT_SETTINGS = { playerBackground: 'blur', // 播放页背景: 'blur', 'solid', 'dark' saveAccountSettingsToFile: true, // 同步账号设置到文件 (默认开启) autoUpdateNetworkList: false, // 自动更新网络歌单 (默认关闭) + networkListAutoCheckInterval: '6h', // 网络歌单自动检测间隔 + favoriteSidebarOrder: [], // 我的收藏侧边栏子项排序 preferServerCache: true, // 优先播放缓存歌曲 (默认开启) remoteSyncUrl: '', // 远程同步地址 remoteSyncCode: '', // 远程同步连接码 @@ -124,6 +126,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 }; // 歌词原始数据,用于设置切换时重新渲染 @@ -139,20 +155,139 @@ 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); } 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; + 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; + let intervalMs = null; + switch (unit) { + 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() { + 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=${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)) { + 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) { + const changedListNames = changedLists.map(escapeHtmlText); + const failedListNames = failedLists.map(escapeHtmlText); + if (changedLists.length > 0) { + showSuccess(`检测到 ${changedLists.length} 个歌单已更新:${changedListNames.join('、')}`); + } else if (failedLists.length === 0) { + showSuccess('所有网络歌单均为最新状态'); + } + if (failedLists.length > 0) { + showError(`部分歌单检测失败:${failedListNames.join('、')}`); + } + } else if (changedLists.length > 0 && window.showToast) { + showToast('info', `检测到 ${changedLists.length} 个网络歌单有更新`, 5000); + } +} + +window.checkNetworkListUpdates = checkNetworkListUpdates; // 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); @@ -606,16 +741,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; @@ -1632,6 +1768,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'); @@ -1644,14 +1782,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 = '
'; @@ -2072,6 +2220,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; } @@ -2084,6 +2234,7 @@ async function loadArtistAlbums(id, source, forceFetch = false) { window.currentArtistAlbumsCache = list; window.currentArtistId = id; + window.currentArtistSource = source; renderArtistAlbumsUI(list); } catch (e) { @@ -2119,7 +2270,7 @@ function renderArtistAlbumsUI(list) { ${album.name}
${album.publishTime} - ${album.total} 首 + ${album.total ?? album.count ?? album.size ?? album.songCount ?? 0} 首
`).join('')} @@ -2448,8 +2599,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) { @@ -2476,17 +2627,18 @@ 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() { +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; - const gap = ''; // 增加间距 // 必须保留 overflow-hidden 以限制宽度 el.classList.remove('truncate'); @@ -2495,12 +2647,22 @@ function applyMarqueeChecks() { // 使用 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); @@ -2515,78 +2677,57 @@ 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'); + img.removeAttribute('data-src'); + }; + }; - // 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; +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 @@ -2712,6 +2853,8 @@ async function resolveSongUrl(song, quality, isSilent = false, isRetry = false, } } + const fallbackRetryMode = (isRetry === 'local_retry' || isRetry === 'download') ? isRetry : true; + for (const step of steps) { if (step === 'degrade') { const nextQuality = isPlatformNotSupported ? null : window.QualityManager.getNextLowerQuality(quality, song); @@ -2721,17 +2864,25 @@ 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) { 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, fallbackRetryMode, isSilent); + return { + ...matchedResult, + songInfo: matchedSong, + switchedSource: true, + originalSource: song.source + }; } } } @@ -2740,12 +2891,126 @@ 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 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); + + 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 (!targetText) return true; + if (!sourceText) return false; + 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) { - if (!song.name || !song.singer) return null; +async function findOtherSourceMatch(song, isSilent = false) { + 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. 获取当前自定义源支持解析的平台(支持的平台) @@ -2768,22 +3033,22 @@ async function findOtherSourceMatch(song) { // 3. 过滤出自定义源支持解析的平台 let searchSources = searchSourcesOrdered; - if (supportedPlatforms) { + if (supportedPlatforms && !options.ignoreSupportedFilter) { searchSources = searchSourcesOrdered.filter(s => supportedPlatforms.has(s)); } // 4. 如果发现没有其他支持的平台可供切换 if (searchSources.length === 0) { console.log(`[AutoSource] 换源跳过:没有其他自定义源支持的平台。当前源: ${song.source}`); - showError('未找到自定义源下支持的平台下的对应歌曲'); - return null; + if (!isSilent) showError('未找到自定义源下支持的平台下的对应歌曲'); + return []; } const query = `${song.name} ${song.singer}`; 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 }) @@ -2795,39 +3060,27 @@ async function findOtherSourceMatch(song) { 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 []; } } @@ -2927,7 +3180,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) { @@ -2939,7 +3193,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) { @@ -3006,7 +3260,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); @@ -3017,6 +3271,7 @@ async function fetchSongUrl(song, quality, isRetry = false, isSilent = false) { sourceType: 'normal', quality: result.type || quality, sourceName: result.sourceName, + songInfo: song, errorMsg: result.errorMsg }; } @@ -3348,13 +3603,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) }) }); @@ -3687,6 +3953,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 会提前返回, @@ -3700,7 +3975,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)); @@ -3713,8 +3988,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}_${currentQuality || targetQuality}`); + playSong(playbackSong, index, targetQuality, noPlay, currentSourceType === 'server_cache' ? 'local_retry' : true); }; audio.addEventListener('error', retryHandler, { once: true }); const cleanup = () => audio.removeEventListener('error', retryHandler); @@ -3748,10 +4023,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 已把队列设为歌单/排行榜, @@ -4925,7 +5200,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) { @@ -4934,6 +5209,7 @@ function loadSettings() { // 同步 UI 状态 syncSettingsUI(); + setupNetworkListAutoCheck(); } // ========== 键盘快捷键逻辑 ========== @@ -5061,7 +5337,10 @@ document.addEventListener('keyup', (e) => { }); async function updateSetting(key, value) { - const restrictedKeys = ['enableServerCache', 'enableServerLyricCache', 'serverCacheLocation', 'enableOnlyDownloadMode']; + 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']; const enableLoginCacheRestriction = window.lx_config?.['user.enableLoginCacheRestriction']; @@ -5082,6 +5361,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 { @@ -5092,6 +5380,9 @@ async function updateSetting(key, value) { } // 实时同步 UI 并应用效果 syncSettingsUI(key, value); + if (key === 'networkListAutoCheckInterval' || key === 'autoUpdateNetworkList') { + setupNetworkListAutoCheck(); + } // [New] Push to server if enabled if (settings.saveAccountSettingsToFile) { @@ -5156,9 +5447,10 @@ const SETTINGS_UI_MAP = { downloadConcurrency: { id: 'setting-download-concurrency', type: 'value', + normalize: normalizeDownloadConcurrency, action: (v) => { if (window.SystemDownloadManager) { - window.SystemDownloadManager.updateMaxConcurrent(parseInt(v)); + window.SystemDownloadManager.updateMaxConcurrent(v); } } }, @@ -5256,6 +5548,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' }, @@ -5303,12 +5596,17 @@ 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]; 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; @@ -5746,7 +6044,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}`); // 成功后给予反馈并刷新列表 @@ -5786,7 +6085,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)); @@ -5982,6 +6282,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; @@ -6867,6 +7169,15 @@ if (favList) { // favList.classList.add('hidden'); // using height transition instead } +function refreshFavoritesChildrenHeight() { + const list = document.getElementById('favorites-children'); + if (!list || list.style.height === '0px' || list.style.height === '') return; + + requestAnimationFrame(() => { + list.style.height = list.scrollHeight + 'px'; + }); +} + function toggleFavorites() { const list = document.getElementById('favorites-children'); const arrow = document.getElementById('favorites-arrow'); @@ -7608,11 +7919,12 @@ 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 syncSettingsUI(); + setupNetworkListAutoCheck(); if (typeof showSuccess === 'function') { showSuccess('已从服务器恢复设置'); } @@ -8167,11 +8479,90 @@ async function handleRemoteOverwriteConnect(silent = false) { } +function getFavoriteSidebarOrder() { + return Array.isArray(settings.favoriteSidebarOrder) ? settings.favoriteSidebarOrder : []; +} + +function getOrderedFavoriteSidebarItems(items) { + const order = getFavoriteSidebarOrder(); + if (!order.length) return items; + + const itemMap = new Map(items.map(item => [item.id, item])); + const orderedItems = []; + order.forEach(id => { + const item = itemMap.get(id); + if (!item) return; + orderedItems.push(item); + itemMap.delete(id); + }); + return [...orderedItems, ...itemMap.values()]; +} + +function persistFavoriteSidebarOrder(ids) { + settings.favoriteSidebarOrder = ids; + window.settings = settings; + try { + localStorage.setItem('lx_settings', JSON.stringify(settings)); + } catch (e) { + console.error('[Settings] 保存收藏侧边栏排序失败:', e); + } + if (settings.saveAccountSettingsToFile) { + pushSettingsToServer(); + } +} + +async function persistUserListOrderFromSidebar(ids) { + if (!currentListData || !Array.isArray(currentListData.userList)) return; + + const currentUserIds = currentListData.userList.map(list => list.id); + const userOrder = ids.filter(id => currentUserIds.includes(id)); + if (userOrder.length !== currentUserIds.length) return; + if (userOrder.every((id, index) => id === currentUserIds[index])) return; + + const listMap = new Map(currentListData.userList.map(list => [list.id, list])); + currentListData.userList = userOrder.map(id => listMap.get(id)).filter(Boolean); + try { + await pushDataChange(); + } catch (e) { + console.error('[Playlist] 保存歌单排序失败:', e); + showError('保存歌单排序失败,请稍后重试'); + } +} + +function initFavoriteSidebarSortable(container) { + if (typeof Sortable === 'undefined' || !container) return; + + try { + const oldSortable = Sortable.get(container); + if (oldSortable) oldSortable.destroy(); + } catch (e) { + console.warn('[Playlist] 重置侧边栏排序失败:', e); + } + + Sortable.create(container, { + animation: 150, + handle: '.favorite-sidebar-drag-handle', + ghostClass: 'opacity-50', + chosenClass: 'bg-emerald-50', + onEnd: () => { + const ids = Array.from(container.querySelectorAll('[data-sidebar-sort-id]')) + .map(el => el.getAttribute('data-sidebar-sort-id')) + .filter(Boolean); + persistFavoriteSidebarOrder(ids); + persistUserListOrderFromSidebar(ids); + } + }); +} + function renderMyLists(data) { const container = document.getElementById('my-lists-container'); container.innerHTML = ''; - if (!data) return; + if (!data) { + container.innerHTML = '
请先在设置中登录
'; + refreshFavoritesChildrenHeight(); + return; + } // Helper to create list item const createItem = (listObj, name, icon, count) => { @@ -8179,6 +8570,7 @@ function renderMyLists(data) { const div = document.createElement('div'); div.className = "px-6 py-2 text-sm t-text-muted hover:t-bg-main cursor-pointer flex items-center group transition-colors overflow-hidden"; div.setAttribute('data-sidebar-list-id', id); + div.setAttribute('data-sidebar-sort-id', id); div.onclick = () => handleListClick(id); // Use createMarqueeHtml for list name @@ -8188,6 +8580,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} `; } div.innerHTML = ` + + + ${opsHtml} ${name.length > 8 ? `
${nameHtml}
` : nameHtml} @@ -8213,35 +8612,41 @@ function renderMyLists(data) { const div = document.createElement('div'); div.className = "px-6 py-2 text-sm t-text-muted hover:t-bg-main cursor-pointer flex items-center group transition-colors overflow-hidden"; div.setAttribute('data-sidebar-list-id', id); + div.setAttribute('data-sidebar-sort-id', id); div.onclick = clickFn; div.innerHTML = ` + + + ${name} 0 `; return div; }; - container.appendChild(createLibItem('__lib_artists__', '收藏歌手', 'fa-user', 'lib-artist-count', handleArtistLibraryClick)); - container.appendChild(createLibItem('__lib_albums__', '收藏专辑', 'fa-compact-disc', 'lib-album-count', handleAlbumLibraryClick)); - // 立即更新数量 - refreshLibrarySidebarCount(); + const sidebarItems = [ + { id: '__lib_artists__', type: 'lib', el: createLibItem('__lib_artists__', '收藏歌手', 'fa-user', 'lib-artist-count', handleArtistLibraryClick) }, + { id: '__lib_albums__', type: 'lib', el: createLibItem('__lib_albums__', '收藏专辑', 'fa-compact-disc', 'lib-album-count', handleAlbumLibraryClick) } + ]; - // Default List if (data.defaultList) { - container.appendChild(createItem('default', '默认列表', 'fa-list', data.defaultList.length)); + sidebarItems.push({ id: 'default', type: 'system', el: createItem('default', '默认列表', 'fa-list', data.defaultList.length) }); } - // Love List if (data.loveList) { - container.appendChild(createItem('love', '我的收藏', 'fa-heart', data.loveList.length)); + sidebarItems.push({ id: 'love', type: 'system', el: createItem('love', '我的收藏', 'fa-heart', data.loveList.length) }); } - // User Lists if (data.userList) { data.userList.forEach(l => { const listLen = l.list ? l.list.length : 0; - container.appendChild(createItem(l, l.name, 'fa-music', listLen)); + sidebarItems.push({ id: l.id, type: 'user', el: createItem(l, l.name, 'fa-music', listLen) }); }); } + getOrderedFavoriteSidebarItems(sidebarItems).forEach(item => container.appendChild(item.el)); + refreshLibrarySidebarCount(); + initFavoriteSidebarSortable(container); + refreshFavoritesChildrenHeight(); + // [Resume] 处理本地列表的自动恢复跳转 if (window._pendingResumeListId) { const listId = window._pendingResumeListId; @@ -8347,23 +8752,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 => { @@ -8376,47 +8764,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() { @@ -8635,7 +8983,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' }); @@ -8646,7 +8995,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(); @@ -8666,6 +9015,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); @@ -9920,9 +10274,8 @@ async function handleTogglePlaylist(listId, btnElement) { } // Cleanup selection - if (typeof deselectAll === 'function') deselectAll(); - if (typeof toggleBatchMode === 'function') toggleBatchMode(); - if (typeof toggleLbBatchMode === 'function') toggleLbBatchMode(); + if (typeof exitBatchMode === 'function') exitBatchMode(); + else if (typeof deselectAll === 'function') deselectAll(); } catch (e) { console.error('[BatchCollect] Sync failed, reverting or refreshing:', e); @@ -12308,5 +12661,3 @@ document.addEventListener('click', (e) => { document.addEventListener('DOMContentLoaded', () => { window.CustomSelectManager.initAll(); }); - - diff --git a/public/music/index.html b/public/music/index.html index c926125..b53531e 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -1299,6 +1299,18 @@

本地

正在加载本地音乐...

+ @@ -1562,13 +1574,13 @@

网络设置

- - + - @@ -1689,6 +1701,26 @@

自动更新

+
+
+
网络歌单自动检测间隔
+
输入值示例:30m / 6h / 1d,开启后自动检测并标记已更新歌单。
+
+ +
+
+
+
检查所有歌单更新
+
手动触发一次网络歌单更新检测,结果会在侧边栏中显示。
+
+ +
+
@@ -4745,4 +4777,4 @@

项目使用声明< - \ No newline at end of file + diff --git a/public/music/js/batch_pagination.js b/public/music/js/batch_pagination.js index 0c43cf4..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) @@ -92,14 +94,10 @@ function selectAllVisible() { updateBatchToolbar(); } -function deselectAll() { +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'); @@ -108,6 +106,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'); @@ -115,13 +129,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() { @@ -250,9 +261,7 @@ async function batchDeleteFromList() { } // Clear selection and exit batch mode - window.selectedItems.clear(); - window.batchMode = false; - toggleBatchMode(); // Update UI + exitBatchMode(); } // Helper: Get current active list ID @@ -449,6 +458,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/download_manager.js b/public/music/js/download_manager.js index 3e18c15..f04926c 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 @@ -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,17 +27,164 @@ 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(); } + 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) return url; + try { + 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; + const decoded = decodeURIComponent(extracted); + return decoded.startsWith('http') ? decoded : extracted; + } catch (e) { + return url; + } + } + // 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)); + } + + getDownloadResolver() { + 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_)?/, '') : ''; + } + + 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; + + 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 && + task.isServer && + task.status === 'finished' && + window.requestServerLyricCache && + window.settings?.enableServerLyricCache !== false && + window.settings?.enableOnlyDownloadMode !== true + ); + } + + 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((synced) => { + if (synced) 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'); + } 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); + } finally { + task.cacheRecheckPending = false; + } + } + async pollServerProgress() { // [Move to top] 对已完成但还未检测过歌词的云端任务,执行检测 // 这样即使当前没有正在下载的任务,刷新页面后也能触发一次歌词状态刷新 @@ -43,19 +194,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 @@ -63,12 +209,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 => { @@ -87,7 +246,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) { @@ -98,25 +259,28 @@ class DownloadManager { task.lastPolledBytes = progressInfo.received || 0; task.lastPolledTime = Date.now(); - task.status = (progressInfo.status === 'finished' || progressInfo.status === 'exists') ? progressInfo.status : 'downloading'; + 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 === '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; + task.missingProgressCount = 0; - if (progressInfo.status === 'tagging' || progressInfo.status === 'finished' || progressInfo.status === 'exists') { - if (progressInfo.status === 'tagging') task.status = 'finished'; - task.progress = 100; - task.errorMsg = ''; - // 成功完成后触发歌词同步(补充) - if (window.requestServerLyricCache && task.status === 'finished') { - window.requestServerLyricCache(task.song, task.quality).then(() => { - // 延时一下再检查,确保后端写入完成 - setTimeout(() => this.checkTaskLyric(task), 2000); - }); - } - this.saveTasks(); - // If it just finished, free up the slot - this.processQueue(); + if (progressInfo.status === 'finished' || progressInfo.status === 'exists') { + this.completeServerTask(task, progressInfo.status === 'exists' ? 'exists' : 'finished'); + return; } else { task.errorMsg = ''; } @@ -126,7 +290,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 // → 如果之前进度很高或在嵌入中,说明已从内存队列移除,逻辑上视为已完成 @@ -136,21 +300,24 @@ class DownloadManager { task.progress = 100; task.errorMsg = ''; task.speed = 0; + task.missingProgressCount = 0; // 成功完成后触发歌词同步(补充) - if (window.requestServerLyricCache) { - window.requestServerLyricCache(task.song, task.quality).then(() => { - setTimeout(() => this.checkTaskLyric(task), 2000); + if (this.shouldAutoSyncLyric(task)) { + window.requestServerLyricCache(task.song, task.quality).then((synced) => { + if (synced) setTimeout(() => this.checkTaskLyric(task), 2000); }); } this.renderTask(task); 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); } @@ -161,15 +328,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 = { @@ -208,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); @@ -271,6 +442,48 @@ 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 + } + }; + } + + 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 ` @@ -285,19 +498,21 @@ 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) { 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; } @@ -307,17 +522,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 = song.taskId || this.createTaskId(isServerTask ? 'server' : 'dl'); this.tasks.push({ id: taskId, song: song, isServer: isServerTask, + serverSongKey, quality: quality, status: 'waiting', errorMsg: '', @@ -338,7 +550,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(); } @@ -355,22 +567,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(); @@ -382,42 +592,49 @@ class DownloadManager { this.renderTask(task); try { - if (typeof resolveSongUrl !== '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'); task.quality = quality; - const result = await resolveSongUrl(task.song, quality, true, true); + const result = await downloadResolver(task.song, quality, 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; } + task.serverSongKey = this.getServerSongKey(resolvedSong, quality); + this.renderTask(task); + + let rawUrl = this.extractRawDownloadUrl(result.url); if (!rawUrl.startsWith('http')) throw new Error('无法获取有效的外部下载地址'); // 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: task.song, - url: rawUrl, - quality, - enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, - embedLyric: !!(window.settings?.embedLyricToFile ?? true) - }) + body: JSON.stringify(payload) }); 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); @@ -464,23 +681,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() : {}) }; - - const resolveRes = await fetch('/api/music/url', { - method: 'POST', - headers, - body: JSON.stringify({ songInfo: task.song, quality }), - signal: task.controller.signal - }); + const downloadResolver = await this.waitForDownloadResolver(); + const resolveData = await downloadResolver(task.song, quality, true); + if (!resolveData || !resolveData.url) throw new Error('No download URL found'); - 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 +720,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 为兼容备选 @@ -621,6 +834,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); @@ -663,8 +877,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', { @@ -673,8 +887,12 @@ 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(); + this.processQueue(); } } else { // 本地任务 @@ -684,7 +902,10 @@ class DownloadManager { } } else if (task.status === 'waiting') { task.status = 'paused'; + task.speed = 0; this.renderTask(task); + this.saveTasks(); + this.updateGlobalProgress(); } } } @@ -695,61 +916,26 @@ 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 { - if (typeof resolveSongUrl !== '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, 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); - } - 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: task.song, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false }) - }); - if (!res.ok) throw new Error('服务器拒绝请求'); - - task.status = 'downloading'; - this.renderTask(task); - } 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) { 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', { @@ -769,7 +955,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); } }); @@ -797,51 +983,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 { - if (typeof resolveSongUrl !== '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, 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); - } - 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: t.song, url: rawUrl, quality, enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false }) - }); - if (!res.ok) throw new Error('服务器拒绝缓存'); - - t.status = 'downloading'; - this.renderTask(t); - } 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'; @@ -854,7 +999,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(); } @@ -862,14 +1007,17 @@ class DownloadManager { clearAll() { // 先弹确认框 if (typeof showSelect === 'function') { - showSelect('停止并清空任务', '确认要立即停止所有进行中的任务并清空列表吗?', { - confirmText: '确认停止', + const hasActiveTasks = this.tasks.some(t => t.status === 'downloading' || t.status === 'waiting' || t.status === 'tagging'); + const title = hasActiveTasks ? '停止并清空任务' : '清空任务列表'; + const message = hasActiveTasks ? '确认要立即停止所有进行中的任务并清空列表吗?' : '确认要清空所有下载任务记录吗?'; + showSelect(title, message, { + confirmText: hasActiveTasks ? '确认停止' : '确认清空', danger: true }).then(confirmed => { 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) { } } }); @@ -880,7 +1028,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)); @@ -908,11 +1057,15 @@ class DownloadManager { // Serialize only the data we need, not the AbortController const data = this.tasks.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, - progress: t.status === 'finished' ? 100 : (t.isServer ? t.progress : 0), + 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, maxRetries: t.maxRetries || 2 @@ -933,16 +1086,17 @@ 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 || '', quality: t.quality || '', // 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, @@ -967,16 +1121,16 @@ 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++; } // 所有任务都纳入进度计算(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') { - pctTotal += (t.progress || 0); + } else if (t.status === 'downloading' || t.status === 'waiting' || t.status === 'tagging') { + pctTotal += t.status === 'tagging' ? 100 : (t.progress || 0); pctCount++; } }); @@ -1024,7 +1178,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 = ` @@ -1033,6 +1187,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 = '已暂停'; @@ -1146,20 +1304,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; } - this.listContainer.innerHTML = this.tasks.map(t => this.renderTaskHtml(t)).join(''); + 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/js/local_music.js b/public/music/js/local_music.js index 6d7e4d7..8119121 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: '', @@ -26,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 = { @@ -142,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 @@ -319,6 +368,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'); @@ -378,6 +428,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 +495,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'); @@ -471,11 +523,66 @@ 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 (this.currentPage > totalPages) this.currentPage = totalPages; + if (this.currentPage < 1) this.currentPage = 1; + 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; + this.bindListEvents(); if (this.displayData.length === 0) { + this.updatePagination(); + if (typeof window.unobserveLazyImages === 'function') { + window.unobserveLazyImages(container); + } container.innerHTML = `
@@ -485,9 +592,18 @@ 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 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; @@ -504,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) => { @@ -521,13 +637,13 @@ window.LocalMusicManager = { const folderIcon = item.folder === 'music' ? '' : ''; html += ` -
+
${index + 1}
@@ -537,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 ? `` : ''} @@ -553,10 +669,10 @@ window.LocalMusicManager = {
${folderIcon} - ${item.source === 'unknown' ? '未知' : item.source} + ${safeSource}
- ${item.subPath ? `${item.subPath}` : ''} + ${item.subPath ? `${safeSubPath}` : ''}
${missingID3 ? '缺标签' : ''} @@ -566,7 +682,7 @@ window.LocalMusicManager = {
${(isUnindexed || this.enableReMapping) ? ` - @@ -582,21 +698,21 @@ window.LocalMusicManager = {