diff --git a/.gitignore b/.gitignore index 273ed13..e430177 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/public/js/config.js b/public/js/config.js index c9c460d..9132916 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -2,6 +2,6 @@ // 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts) // 服务端拦截 /js/config.js 请求, 读取此处版本号并合并服务端配置后返回 window.CONFIG = { - buildHash: 'c95106b', + buildHash: 'ba6046e', version: 'v1.9.4', }; diff --git a/public/music/app.js b/public/music/app.js index e742287..20ad1c2 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; @@ -1477,30 +1613,36 @@ function getQualityTags(item) { let has320 = false; let hasFlac = false; let hasHiRes = false; + let hasAtmos = false; let hasMaster = false; if (Array.isArray(rawTypes)) { + const isConcrete = t => !(t && t.isPlatformQuality); has320 = rawTypes.some(t => t.type === '320k'); hasFlac = rawTypes.some(t => t.type === 'flac'); - hasHiRes = rawTypes.some(t => t.type === 'flac24bit'); - hasMaster = rawTypes.some(t => t.type === 'master'); + hasHiRes = rawTypes.some(t => (t.type === 'flac24bit' || t.type === 'hires') && isConcrete(t)); + hasAtmos = rawTypes.some(t => (t.type === 'atmos' || t.type === 'atmos_plus') && isConcrete(t)); + hasMaster = rawTypes.some(t => t.type === 'master' && isConcrete(t)); } else { has320 = !!rawTypes['320k']; hasFlac = !!rawTypes['flac']; - hasHiRes = !!rawTypes['flac24bit']; - hasMaster = !!rawTypes['master']; + hasHiRes = !!(rawTypes['flac24bit'] && !rawTypes['flac24bit'].isPlatformQuality) || !!(rawTypes.hires && !rawTypes.hires.isPlatformQuality); + hasAtmos = !!(rawTypes.atmos && !rawTypes.atmos.isPlatformQuality) || !!(rawTypes.atmos_plus && !rawTypes.atmos_plus.isPlatformQuality); + hasMaster = !!(rawTypes.master && !rawTypes.master.isPlatformQuality); } // [New] 额外检查具体音质字段 (适用于本地歌曲或已确定音质的播放中歌曲) const q = item.quality || item.type; if (q) { if (q === 'master') hasMaster = true; - else if (q === 'flac24bit') hasHiRes = true; + else if (q === 'atmos' || q === 'atmos_plus') hasAtmos = true; + else if (q === 'flac24bit' || q === 'hires') hasHiRes = true; else if (q === 'flac') hasFlac = true; else if (q === '320k') has320 = true; } if (hasMaster) tags.push('Master'); + else if (hasAtmos) tags.push('Atmos'); else if (hasHiRes) tags.push('Hi-Res'); else if (hasFlac) tags.push('无损'); else if (has320) tags.push('高品质'); @@ -1632,6 +1774,9 @@ window.searchBySinger = searchBySinger; let currentArtistId = null; let currentArtistSource = 'wy'; let currentArtistInfo = null; +window.currentArtistId = null; +window.currentArtistSource = 'wy'; +window.currentArtistOrder = 'hot'; async function enterArtist(id, source = 'wy', order = 'hot', tab = 'songs', isBack = false) { const typeEl = document.getElementById('search-type'); @@ -1644,14 +1789,30 @@ async function enterArtist(id, source = 'wy', order = 'hot', tab = 'songs', isBa window.history.pushState({ page: 'search-detail' }, ''); } + const previousArtistOrder = window.currentArtistOrder || 'hot'; + const isDifferentArtist = String(currentArtistId || '') !== String(id) || currentArtistSource !== source; + const isDifferentOrder = previousArtistOrder !== order; + if (isDifferentArtist || (tab === 'songs' && isDifferentOrder)) { + window.currentArtistSongsCache = null; + window.artistSongsPage = 1; + if (window.ListSearch) window.ListSearch.resetState(); + } + if (isDifferentArtist) { + 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 = '
'; @@ -1875,11 +2036,19 @@ async function loadArtistSongs(id, source, order, forceFetch = false) { return; } + renderArtistSongsLoading(); + try { const res = await fetch(`${API_BASE}/artistSongs?id=${id}&source=${source}&order=${order}`); if (!res.ok) throw new Error('Failed to fetch songs'); const list = await res.json(); + const isCurrentRequest = String(window.currentArtistId) === String(id) + && window.currentArtistSource === source + && window.currentArtistOrder === order + && window.currentArtistTab === 'songs'; + if (!isCurrentRequest) return; + // [Fix] 唯一 ID list.forEach((item, idx) => { if (!item.id || item.id === 'undefined') { @@ -1901,6 +2070,18 @@ async function loadArtistSongs(id, source, order, forceFetch = false) { } } +function renderArtistSongsLoading() { + const content = document.getElementById('artist-detail-content'); + if (!content) return; + window.viewingPlaylist = []; + content.innerHTML = ` +
+ + 正在加载歌曲... +
+ `; +} + function renderArtistSongsUI(list, page) { const content = document.getElementById('artist-detail-content'); if (!content) return; @@ -2068,27 +2249,97 @@ function artistSongsNextPage() { window.artistSongsPrevPage = artistSongsPrevPage; window.artistSongsNextPage = artistSongsNextPage; +const ARTIST_ALBUM_PAGE_SIZE = 50; +const ARTIST_ALBUM_MAX_PAGES = 100; + +function renderArtistAlbumsLoading(loaded = 0, total = 0) { + const content = document.getElementById('artist-detail-content'); + if (!content) return; + const progressText = loaded > 0 + ? '正在加载全部专辑,已读取 ' + loaded + (total > 0 ? '/' + total : '') + ' 张...' + : '正在加载全部专辑...'; + const wrapper = document.createElement('div'); + const icon = document.createElement('i'); + const label = document.createElement('span'); + wrapper.className = 'flex items-center justify-center py-12 t-text-muted'; + icon.className = 'fas fa-spinner fa-spin text-2xl text-emerald-500 mr-3'; + label.className = 'text-sm font-medium'; + label.textContent = progressText; + wrapper.append(icon, label); + content.replaceChildren(wrapper); +} + +async function fetchAllArtistAlbums(id, source, signal, onProgress) { + const albums = []; + const albumKeys = new Set(); + let total = 0; + + for (let page = 1; page <= ARTIST_ALBUM_MAX_PAGES; page++) { + const query = new URLSearchParams({ id: String(id), source, page: String(page) }); + const res = await fetch(API_BASE + '/artistAlbums?' + query.toString(), { signal }); + if (!res.ok) throw new Error('Failed to fetch artist albums page ' + page); + + const data = await res.json(); + const pageList = Array.isArray(data.list) ? data.list : []; + const previousCount = albums.length; + total = Math.max(total, Number(data.total) || 0); + + pageList.forEach((album, index) => { + const albumId = album.id ?? album.mid; + const key = albumId !== undefined && albumId !== null && albumId !== '' + ? source + ':' + albumId + : source + ':page:' + page + ':index:' + index; + if (albumKeys.has(key)) return; + albumKeys.add(key); + albums.push({ ...album, source: album.source || source }); + }); + + if (typeof onProgress === 'function') onProgress(albums.length, total); + + const reachedTotal = total > 0 && albums.length >= total; + const pageExhausted = pageList.length < ARTIST_ALBUM_PAGE_SIZE; + const noNewAlbums = albums.length === previousCount; + if (pageList.length === 0 || reachedTotal || pageExhausted || noNewAlbums) break; + } + + return { list: albums, total: total || albums.length }; +} + async function loadArtistAlbums(id, source, forceFetch = false) { - if (!forceFetch && window.currentArtistAlbumsCache && window.currentArtistId === id && window.currentArtistSource === source) { + if (!forceFetch && window.currentArtistAlbumsCache && String(window.currentArtistId) === String(id) && window.currentArtistSource === source) { renderArtistAlbumsUI(window.currentArtistAlbumsCache); return; } + renderArtistAlbumsLoading(); + try { - const res = await fetch(`${API_BASE}/artistAlbums?id=${id}&source=${source}`); - if (!res.ok) throw new Error('Failed to fetch albums'); - const data = await res.json(); - const list = data.list || []; + const data = await fetchAllArtistAlbums(id, source, undefined, (loaded, total) => { + const stillViewingArtist = String(window.currentArtistId) === String(id) && window.currentArtistSource === source; + if (window.currentArtistTab === 'albums' && stillViewingArtist) { + renderArtistAlbumsLoading(loaded, total); + } + }); + const list = data.list; + const stillViewingArtist = String(window.currentArtistId) === String(id) && window.currentArtistSource === source; + if (!stillViewingArtist) return; window.currentArtistAlbumsCache = list; - window.currentArtistId = id; + window.currentArtistAlbumsTotal = data.total; - renderArtistAlbumsUI(list); + if (window.currentArtistTab === 'albums') { + renderArtistAlbumsUI(list); + } } catch (e) { - showError(`加载专辑失败: ${e.message}`); - goBackToSearch(); + const stillViewingArtist = String(window.currentArtistId) === String(id) && window.currentArtistSource === source; + if (stillViewingArtist) { + showError(`加载专辑失败: ${e.message}`); + goBackToSearch(); + } else { + console.warn('[ArtistAlbums] 已离开歌手页,忽略专辑加载失败:', e); + } } } @@ -2101,13 +2352,18 @@ function renderArtistAlbumsUI(list) { return; } - let html = ` + const artistName = currentArtistInfo?.name || ''; + const html = `
- ${list.map(album => ` -
+ ${list.map((album, index) => { + const albumId = album.id ?? album.mid; + const albumSource = album.source || window.currentArtistSource || 'wy'; + const albumName = album.name || '未知专辑'; + const favorited = isAlbumFavorited(albumId, albumSource); + return ` +
-
@@ -2115,20 +2371,107 @@ function renderArtistAlbumsUI(list) {
+
+ + +
- ${album.name} + ${escapeHtmlText(albumName)}
- ${album.publishTime} - ${album.total} 首 + ${escapeHtmlText(album.publishTime || '')} + ${album.total ?? album.count ?? album.size ?? album.songCount ?? 0} 首
- `).join('')} + `; + }).join('')}
`; content.innerHTML = html; + + content.querySelectorAll('.artist-album-card').forEach(card => { + card.addEventListener('click', () => { + const album = list[Number(card.dataset.albumIndex)]; + if (album) enterAlbum(album.id ?? album.mid, album.source || window.currentArtistSource || 'wy'); + }); + }); + content.querySelectorAll('.artist-album-download-btn').forEach(button => { + button.addEventListener('click', async event => { + event.stopPropagation(); + const album = list[Number(button.dataset.albumIndex)]; + if (album) await downloadArtistAlbumSongs(album, button); + }); + }); + content.querySelectorAll('.artist-album-favorite-btn').forEach(button => { + button.addEventListener('click', async event => { + event.stopPropagation(); + const album = list[Number(button.dataset.albumIndex)]; + if (!album) return; + const albumId = album.id ?? album.mid; + const albumSource = album.source || window.currentArtistSource || 'wy'; + const favorited = await toggleAlbumFavorite(albumId, albumSource, album.name || '未知专辑', getImgUrl(album), album.artistName || album.singer || artistName); + button.className = 'artist-album-favorite-btn w-8 h-8 rounded-full flex items-center justify-center transition-all shadow-sm ' + (favorited ? 'bg-rose-500 text-white opacity-100' : 'bg-black/45 hover:bg-rose-500 text-white opacity-100 sm:opacity-0 group-hover:opacity-100'); + button.title = favorited ? '取消收藏' : '收藏专辑'; + }); + }); } window.renderArtistAlbumsUI = renderArtistAlbumsUI; +async function downloadArtistAlbumSongs(album, button) { + if (typeof window.batchDownloadSongs !== 'function') { + showError('批量下载功能未就绪'); + return; + } + + const albumId = album.id ?? album.mid; + const albumSource = album.source || window.currentArtistSource || 'wy'; + const albumName = album.name || '未知专辑'; + if (albumId === undefined || albumId === null || albumId === '') { + showError(`专辑「${albumName}」缺少有效 ID,无法下载`); + return; + } + const icon = button?.querySelector('i'); + if (button) button.disabled = true; + if (icon) icon.className = 'fas fa-spinner fa-spin text-xs'; + + try { + const query = new URLSearchParams({ id: String(albumId), source: albumSource }); + const res = await fetch(API_BASE + '/albumSongs?' + query.toString()); + if (!res.ok) throw new Error('HTTP ' + res.status); + const data = await res.json(); + const rawSongs = Array.isArray(data.list) ? data.list : (Array.isArray(data) ? data : []); + if (rawSongs.length === 0) { + showError(`专辑「${albumName}」没有可下载的歌曲`); + return; + } + + const songs = rawSongs.map(song => ({ + ...song, + source: song.source || albumSource, + albumName: song.albumName || albumName, + meta: { + ...(song.meta || {}), + albumId: song.meta?.albumId || song.albumId || albumId, + albumName: song.meta?.albumName || albumName + } + })); + await window.batchDownloadSongs(songs, { + clearSelection: false, + selectionLabel: `专辑「${albumName}」共 ${songs.length} 首歌曲` + }); + } catch (e) { + console.error('[ArtistAlbums] 读取专辑歌曲失败:', albumName, e); + showError(`读取专辑「${albumName}」失败: ${e.message}`); + } finally { + if (button) button.disabled = false; + if (icon) icon.className = 'fas fa-download text-xs'; + } +} +window.downloadArtistAlbumSongs = downloadArtistAlbumSongs; + async function enterAlbum(id, source = 'wy') { // 保存进入专辑前的上下文,如果是从歌手页进入,则记录歌手 ID const artistHeader = document.getElementById('artist-detail-header'); @@ -2448,8 +2791,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 +2819,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 +2839,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 +2869,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 +3045,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 +3056,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 +3083,129 @@ 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) => { + const requestedQuality = preferredQuality || settings.preferredQuality || '320k'; + let candidateQuality = window.QualityManager?.QUALITY_PRIORITY?.includes(requestedQuality) + ? requestedQuality + : (window.QualityManager + ? window.QualityManager.getBestQuality(candidateSong, requestedQuality) + : requestedQuality); + + 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 +3228,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 +3255,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; + const score = getSongMatchScore(item, song); + if (score < 0) 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; - } - - 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,19 +3375,21 @@ 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) { - console.log(`[Cache] Server Hit: ${cleanedSong.name} (${quality})`); + const actualQuality = cacheResult.quality || quality; + console.log(`[Cache] Server Hit: ${cleanedSong.name} (${actualQuality})`); let serverCacheUrl = cacheResult.url; // 应用代理逻辑 (以防服务器缓存返回的是原始 HTTP 链接) serverCacheUrl = await applyAutoProxy(serverCacheUrl, song); - return { url: serverCacheUrl, sourceType: 'server_cache', quality }; + return { url: serverCacheUrl, sourceType: 'server_cache', quality: actualQuality }; } } - 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 +3456,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 +3467,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 +3799,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 +4149,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 +4171,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 +4184,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 +4219,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 已把队列设为歌单/排行榜, @@ -3959,7 +4430,7 @@ function updatePlaylist(list, startIndex = 0, scope = 'local_list', shouldAddToD const p1 = window.QualityManager.QUALITY_PRIORITY.indexOf(existingQuality); const p2 = window.QualityManager.QUALITY_PRIORITY.indexOf(qualityAttr); - // Priority index: master(0) > flac(2) > 320k(3) + // Priority index: smaller means better quality. // Lower index is higher quality if (p2 !== -1 && (p1 === -1 || p2 < p1)) { deduplicated[existingIdx] = song; @@ -4925,7 +5396,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 +5405,7 @@ function loadSettings() { // 同步 UI 状态 syncSettingsUI(); + setupNetworkListAutoCheck(); } // ========== 键盘快捷键逻辑 ========== @@ -5061,7 +5533,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 +5557,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 +5576,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 +5643,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 +5744,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 +5792,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; @@ -5644,7 +6138,11 @@ function renderCacheList() { const q = (item.quality || '').toLowerCase(); const qName = window.QualityManager?.getQualityDisplayName(q) || q.toUpperCase(); - if (q === 'flac24bit' || q === 'hr') { + if (q === 'master') { + qTagHtml = `${qName}`; + } else if (q === 'atmos' || q === 'atmos_plus') { + qTagHtml = `${qName}`; + } else if (q === 'flac24bit' || q === 'hires' || q === 'hr') { qTagHtml = `${qName}`; } else if (q === 'flac' || q === 'sq' || q === 'ape') { qTagHtml = `${qName}`; @@ -5746,7 +6244,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 +6285,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 +6482,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 +7369,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'); @@ -7234,7 +7745,7 @@ function renderLibraryAlbums(list) { div.dataset.libAlbumId = item.id; div.dataset.libAlbumSource = item.source; div.onclick = (e) => { - if (e.target.closest('.lib-batch-check') || e.target.closest('.lib-fav-btn')) return; + if (e.target.closest('.lib-batch-check') || e.target.closest('.lib-fav-btn') || e.target.closest('.lib-album-download-btn')) return; if (window.libraryBatchMode === 'album') { toggleLibAlbumBatchSelect(item.id); return; @@ -7249,6 +7760,15 @@ function renderLibraryAlbums(list) { +
+ + +
${item.name}
@@ -7257,12 +7777,12 @@ function renderLibraryAlbums(list) { ${item.list && item.list.length ? `(${item.list.length} 首)` : ''} ${getSourceTag ? getSourceTag(item.source) : ''} -
- `; + `; + const downloadButton = div.querySelector('.lib-album-download-btn'); + downloadButton?.addEventListener('click', async event => { + event.stopPropagation(); + await downloadArtistAlbumSongs(item, downloadButton); + }); grid.appendChild(div); }); } @@ -7608,11 +8128,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 +8688,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 +8779,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 +8789,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 +8821,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; @@ -8257,6 +8871,14 @@ function handleListClick(listId, skipAutoUpdate = false) { if (!currentListData) return; + if (!skipAutoUpdate) { + // Selections belong to the previously rendered list. Keeping them here makes + // the toolbar count include invisible songs after opening another favorite list. + window.selectedItems?.clear(); + window.selectedSongObjects?.clear(); + if (typeof updateBatchToolbar === 'function') updateBatchToolbar(); + } + // Mobile: Close sidebar when a list is selected if (window.innerWidth < 1025) { const sidebar = document.getElementById('main-sidebar'); @@ -8347,23 +8969,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 +8981,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 +9200,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 +9212,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 +9232,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 +10491,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 +12878,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..9ea0002 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -1101,7 +1101,23 @@

本地 + onclick="window.LocalMusicManager.toggleFilterTag('quality','flac24bit')">24bit + + + + @@ -1299,6 +1315,18 @@

本地

正在加载本地音乐...

+ @@ -1402,10 +1430,14 @@

@@ -1562,13 +1594,13 @@

网络设置

- - + - @@ -1689,6 +1721,26 @@

自动更新

+
+
+
网络歌单自动检测间隔
+
输入值示例:30m / 6h / 1d,开启后自动检测并标记已更新歌单。
+
+ +
+
+
+
检查所有歌单更新
+
手动触发一次网络歌单更新检测,结果会在侧边栏中显示。
+
+ +
+
@@ -4745,4 +4797,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..a534e40 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,14 @@ 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; + this.serverPollInFlight = false; + this.serverQueueSyncInFlight = false; + this.serverQueuePending = false; + this.serverQueueLoaded = false; // Speed calculation this.lastTotalBytes = 0; @@ -23,39 +31,354 @@ 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(); + setTimeout(() => this.syncServerConcurrency(), 300); + setTimeout(() => this.syncServerQueue(true), 500); + } + + 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; + } + } + + shouldUseNativeDownload(batchSize, quality) { + const memoryHeavyQualities = ['flac', 'flac24bit', 'hires', 'atmos', 'atmos_plus', 'master']; + return batchSize > 1 || memoryHeavyQualities.includes(quality); + } + + triggerNativeDownload(url, filename) { + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.rel = 'noopener'; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + link.remove(); + } + + getDownloadExtension(url, quality) { + try { + const pathname = new URL(url, window.location.origin).pathname; + const match = pathname.match(/\.([a-z0-9]{2,5})$/i); + if (match && ['mp3', 'flac', 'm4a', 'ogg', 'wav', 'ape'].includes(match[1].toLowerCase())) { + return match[1].toLowerCase(); + } + } catch (e) { } + + if (quality === '128k' || quality === '192k' || quality === '320k') return 'mp3'; + if (quality === 'atmos' || quality === 'atmos_plus') return 'm4a'; + if (['flac', 'flac24bit', 'hires', 'master'].includes(quality)) return 'flac'; + return 'mp3'; } // 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.syncServerConcurrency(); this.processQueue(); } + async syncServerConcurrency() { + try { + await this.requestServerQueue('/api/music/cache/queue/concurrency', { concurrency: this.maxConcurrent }); + } catch (error) { + console.warn('[DownloadManager] Failed to sync server concurrency:', error); + } + } + + 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; + } + + getServerQueueHeaders() { + return { 'Content-Type': 'application/json', ...(window.getUserAuthHeaders ? window.getUserAuthHeaders() : {}) }; + } + + async requestServerQueue(path, body) { + const options = { method: body === undefined ? 'GET' : 'POST', headers: this.getServerQueueHeaders() }; + if (body !== undefined) options.body = JSON.stringify(body); + const response = await fetch(path, options); + const result = await response.json().catch(() => ({})); + if (!response.ok || result.success === false) throw new Error(result.message || `HTTP ${response.status}`); + return result.data; + } + + async enqueueServerTasks(tasks) { + if (!tasks.length) return; + this.serverQueuePending = true; + try { + const headers = this.getServerQueueHeaders(); + const payload = { + concurrency: this.maxConcurrent, + tasks: tasks.map(task => ({ + id: task.id, + songInfo: this.getSongInfoForServer(task.song), + quality: task.quality, + enableOnlyDownloadMode: window.settings?.enableOnlyDownloadMode || false, + cacheLyric: window.settings?.enableServerLyricCache !== false, + embedLyric: !!(window.settings?.embedLyricToFile ?? true) + })) + }; + if (headers['x-frontend-auth'] && window.settings?.serverCacheNamingPattern) { + payload.namingPattern = window.settings.serverCacheNamingPattern; + } + await this.requestServerQueue('/api/music/cache/queue', payload); + tasks.forEach(task => { + task.serverManaged = true; + task.serverQueueRegistered = true; + task.serverQueueId = task.id; + task.status = 'waiting'; + task.errorMsg = ''; + }); + await this.syncServerQueue(true); + } catch (error) { + tasks.forEach(task => { + task.serverQueueRegistered = false; + task.status = 'error'; + task.errorMsg = error.message || '服务器队列登记失败'; + }); + this.renderList(); + this.saveTasks(); + } finally { + this.serverQueuePending = false; + } + } + + async syncServerQueue(render = false) { + if (this.serverQueueSyncInFlight) return; + this.serverQueueSyncInFlight = true; + try { + const items = await this.requestServerQueue('/api/music/cache/queue'); + if (!Array.isArray(items)) return; + const remoteIds = new Set(); + const updatedTasks = []; + items.forEach(item => { + remoteIds.add(item.id); + let task = this.tasks.find(t => t.isServer && (t.serverQueueId === item.id || t.id === item.id)); + if (!task) { + task = { + id: item.id, + song: item.songInfo || {}, + isServer: true, + serverManaged: true, + serverQueueRegistered: true, + serverQueueId: item.id, + serverSongKey: item.songKey || '', + quality: item.quality || item.requestedQuality || '', + status: item.status || 'waiting', + progress: item.progress || 0, + downloadedBytes: item.received || 0, + totalBytes: item.total || 0, + speed: item.speed || 0, + errorMsg: item.errorMsg || '', + retryCount: 0, + maxRetries: 2, + controller: null + }; + this.tasks.push(task); + } else { + task.song = item.songInfo || task.song; + task.serverManaged = true; + task.serverQueueRegistered = true; + task.serverQueueId = item.id; + task.serverSongKey = item.songKey || task.serverSongKey; + task.quality = item.quality || task.quality; + task.status = item.status || task.status; + task.progress = item.progress || 0; + task.downloadedBytes = item.received || 0; + task.totalBytes = item.total || 0; + task.speed = item.speed || 0; + task.errorMsg = item.errorMsg || ''; + } + updatedTasks.push(task); + }); + if (!this.serverQueuePending) { + this.tasks = this.tasks.filter(task => !task.serverManaged || remoteIds.has(task.serverQueueId || task.id)); + } + this.serverQueueLoaded = true; + if (render) this.renderList(); + else updatedTasks.forEach(task => this.renderTask(task)); + this.saveTasks(); + } catch (error) { + console.warn('[DownloadManager] Failed to sync server queue:', error); + } finally { + this.serverQueueSyncInFlight = false; + } + } + + 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; + } + + getSongIdentity(songInfo) { + const meta = songInfo?.meta || {}; + const source = songInfo?.source || meta.source || 'unknown'; + const id = songInfo?.songmid || songInfo?.songId || meta.songmid || meta.songId || + songInfo?.id || songInfo?.hash || songInfo?.copyrightId || songInfo?.mid || + songInfo?.mediaMid || songInfo?.strMediaMid; + if (id !== undefined && id !== null && id !== '') return `${source}:${id}`; + + return `${source}:${songInfo?.name || ''}:${songInfo?.singer || ''}:${songInfo?.albumName || ''}:${songInfo?.interval || ''}`; + } + + 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() { + if (this.serverPollInFlight) return; + this.serverPollInFlight = true; + try { + await this.syncServerQueue(false); // [Move to top] 对已完成但还未检测过歌词的云端任务,执行检测 // 这样即使当前没有正在下载的任务,刷新页面后也能触发一次歌词状态刷新 - this.tasks.filter(t => t.isServer && t.status === 'finished' && t.hasLyric === undefined).forEach(t => { + this.tasks.filter(t => t.isServer && t.status === 'finished' && t.hasLyric === undefined).slice(0, 3).forEach(t => { t.hasLyric = 'checking'; this.checkTaskLyric(t); }); // 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.serverManaged && (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 +386,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 +423,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 +436,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 +467,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,24 +477,40 @@ 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); + } else if (task.nativeDownloadDispatched) { + task.missingProgressCount = (task.missingProgressCount || 0) + 1; + if (task.missingProgressCount >= 6) { + task.status = 'error'; + task.speed = 0; + task.errorMsg = '浏览器未启动下载'; + this.renderTask(task); + this.saveTasks(); + this.processQueue(); + } } } }); - } } catch (e) { console.error('[DownloadManager] Server poll error:', e); } + } finally { + this.serverPollInFlight = false; + } } // [New] 检测任务歌词是否存在 @@ -161,15 +518,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 +568,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 +632,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 +688,22 @@ 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 quality = song.quality || (window.QualityManager ? window.QualityManager.getBestQuality(song, targetPref) : targetPref); const cacheResult = await checkServerCache(song, quality, true); return { song, quality, cacheResult }; - })); + }); let skipCount = 0; + const addedServerTasks = []; 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; } @@ -305,19 +711,27 @@ 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')); + const songIdentity = this.getSongIdentity(song); + const existing = this.tasks.find(t => + this.getSongIdentity(t.song) === songIdentity && + t.quality === quality && + (t.status === 'waiting' || t.status === 'starting' || t.status === 'downloading' || t.status === 'tagging') + ); 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'); + const useNativeDownload = !isServerTask && this.shouldUseNativeDownload(songs.length, quality); - this.tasks.push({ + const task = { id: taskId, song: song, isServer: isServerTask, + serverManaged: isServerTask, + serverQueueRegistered: false, + serverQueueId: isServerTask ? taskId : null, + useNativeDownload, + nativeDownloadDispatched: false, + serverSongKey, quality: quality, status: 'waiting', errorMsg: '', @@ -330,7 +744,9 @@ class DownloadManager { controller: null, collisionInfo: cacheResult.isCollision ? cacheResult : null, cacheUrl: cacheResult.exists ? cacheResult.url : null // [新增] 保存缓存地址 - }); + }; + this.tasks.push(task); + if (isServerTask) addedServerTasks.push(task); } } @@ -338,7 +754,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(); } @@ -346,31 +762,32 @@ class DownloadManager { this.renderList(); this.processQueue(); this.saveTasks(); + await this.enqueueServerTasks(addedServerTasks); } // Process the queue based on concurrency limits processQueue() { // Recalculate true active count including triggered server tasks const localActive = this.tasks.filter(t => !t.isServer && t.status === 'downloading').length; - const serverActive = this.tasks.filter(t => t.isServer && (t.status === 'downloading' || t.status === 'tagging')).length; + const serverActive = this.tasks.filter(t => t.isServer && !t.serverManaged && (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' && !t.serverManaged); + if (!nextTask) break; + if (nextTask.useNativeDownload && this.activeCount > 0) 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(); + if (nextTask.useNativeDownload) break; } this.renderList(); this.updateGlobalProgress(); @@ -382,42 +799,51 @@ 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 requestedQuality = task.quality || (window.QualityManager ? window.QualityManager.getBestQuality(task.song, window.settings?.preferredQuality || '320k') : '320k'); + task.quality = requestedQuality; - const result = await resolveSongUrl(task.song, quality, true, true); + const result = await downloadResolver(task.song, requestedQuality, 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; + const resolvedQuality = result.quality || result.type || requestedQuality; + if (resolvedSong !== task.song) { + task.song = resolvedSong; } + task.quality = resolvedQuality; + task.serverSongKey = this.getServerSongKey(resolvedSong, resolvedQuality); + 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: resolvedQuality, + 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); @@ -457,32 +883,22 @@ class DownloadManager { finalUrl += (finalUrl.includes('?') ? '&' : '?') + `token=${encodeURIComponent(authToken)}`; } - // 推断后缀 - try { - const urlObj = new URL(finalUrl, window.location.origin); - const path = urlObj.pathname; - if (path.includes('.')) ext = path.split('.').pop(); - } catch (e) { } + ext = this.getDownloadExtension(finalUrl, quality); } 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 - }); - - 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 downloadResolver = await this.waitForDownloadResolver(); + 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; + const resolvedQuality = resolveData.quality || resolveData.type || quality; + if (resolvedSong !== task.song) { + task.song = resolvedSong; + } + task.quality = resolvedQuality; + this.renderTask(task); finalUrl = resolveData.url; - ext = resolveData.type || 'mp3'; - if (ext.startsWith('flac')) ext = 'flac'; // Handle flac24bit -> flac - if (ext === '128k' || ext === '320k') ext = 'mp3'; + ext = this.getDownloadExtension(finalUrl, resolvedQuality); } // Determine filename with collision handling @@ -508,6 +924,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 为兼容备选 @@ -539,6 +956,19 @@ class DownloadManager { console.log('[DownloadManager] Simple download:', finalUrl); } + if (task.useNativeDownload) { + if (task.controller.signal.aborted) throw new DOMException('Download paused', 'AbortError'); + this.triggerNativeDownload(finalUrl, filename); + task.nativeDownloadDispatched = true; + task.status = 'downloading'; + task.progress = 0; + task.speed = 0; + task.controller = null; + this.renderTask(task); + this.saveTasks(); + return; + } + // 2. Fetch the actual file using Streams to track progress const response = await fetch(finalUrl, { signal: task.controller.signal }); if (!response.ok) throw new Error(`HTTP ${response.status} when fetching file`); @@ -621,6 +1051,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,18 +1094,22 @@ 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', { method: 'POST', headers, - body: JSON.stringify({ songKey }) + body: JSON.stringify(task.serverManaged ? { queueId: task.serverQueueId || task.id } : { 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 +1119,10 @@ class DownloadManager { } } else if (task.status === 'waiting') { task.status = 'paused'; + task.speed = 0; this.renderTask(task); + this.saveTasks(); + this.updateGlobalProgress(); } } } @@ -695,61 +1133,39 @@ class DownloadManager { task.status = 'waiting'; task.downloadedBytes = 0; + task.totalBytes = 0; task.progress = 0; - 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(); + task.speed = 0; + task.errorMsg = ''; + task.missingProgressCount = 0; + task.cacheRecheckPending = false; + task.lastPolledBytes = undefined; + task.lastPolledTime = undefined; + task.controller = null; + if (task.serverManaged) { + const request = task.serverQueueRegistered === false + ? this.enqueueServerTasks([task]) + : this.requestServerQueue('/api/music/cache/queue/resume', { id: task.serverQueueId || task.id }); + request.catch(error => { + task.status = 'error'; + task.errorMsg = error.message || '继续任务失败'; + this.renderTask(task); + }); } + this.renderTask(task); + 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.serverManaged) { + this.requestServerQueue('/api/music/cache/queue/remove', { id: task.serverQueueId || task.id }) + .catch(e => console.warn('[DownloadManager] Failed to remove server queue task:', e)); + } else 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', { @@ -768,27 +1184,74 @@ class DownloadManager { } pauseAll() { + const headers = { 'Content-Type': 'application/json', ...(window.getUserAuthHeaders ? window.getUserAuthHeaders() : {}) }; + const hasManagedServerTasks = this.tasks.some(t => t.serverManaged && ['waiting', 'downloading', 'tagging'].includes(t.status)); + if (hasManagedServerTasks) { + fetch('/api/music/cache/stop', { + method: 'POST', headers, body: JSON.stringify({ all: true }) + }).catch(e => console.warn('[DownloadManager] Failed to pause persistent server queue:', e)); + } this.tasks.forEach(t => { - if (t.status === 'downloading' || t.status === 'waiting') { - this.pauseTask(t.id); + if (t.status !== 'downloading' && t.status !== 'waiting' && t.status !== 'tagging' && t.status !== 'starting') return; + if (t.nativeDownloadDispatched && t.status === 'downloading') return; + + if (t.isServer && !t.serverManaged) { + const songKey = this.getTaskServerSongKey(t); + fetch('/api/music/cache/stop', { + method: 'POST', + headers, + body: JSON.stringify({ songKey }) + }).catch(e => console.warn('[DownloadManager] Failed to stop server task:', e)); + } else if (t.controller) { + t.controller.abort(); } + + t.status = 'paused'; + t.speed = 0; + t.errorMsg = '已暂停'; }); + this.activeCount = 0; + this.renderList(); + this.saveTasks(); + this.updateGlobalProgress(); } resumeAll() { + const hasManagedServerTasks = this.tasks.some(t => t.serverManaged && (t.status === 'paused' || t.status === 'error')); + if (hasManagedServerTasks) { + this.requestServerQueue('/api/music/cache/queue/resume', { all: true }) + .catch(e => console.warn('[DownloadManager] Failed to resume persistent server queue:', e)); + } this.tasks.forEach(t => { - if (t.status === 'paused') { - this.resumeTask(t.id); - } + if (t.status !== 'paused') return; + t.status = 'waiting'; + t.downloadedBytes = 0; + t.totalBytes = 0; + t.progress = 0; + t.speed = 0; + t.errorMsg = ''; + t.missingProgressCount = 0; + t.cacheRecheckPending = false; + t.lastPolledBytes = undefined; + t.lastPolledTime = undefined; + t.controller = null; }); + this.renderList(); + this.saveTasks(); + this.processQueue(); } retryAllFailed() { // 取出所有失败任务的快照,避免在遍历同时修改数组引起问题 const failedTasks = this.tasks.filter(t => t.status === 'error'); if (failedTasks.length === 0) return; + const unregisteredServerTasks = failedTasks.filter(t => t.serverManaged && t.serverQueueRegistered === false); failedTasks.forEach(t => { + if (t.serverManaged) { + if (t.serverQueueRegistered !== false) this.requestServerQueue('/api/music/cache/queue/resume', { id: t.serverQueueId || t.id }) + .catch(e => console.warn('[DownloadManager] Failed to retry server queue task:', e)); + } t.retryCount = 0; t.downloadedBytes = 0; t.progress = 0; @@ -797,51 +1260,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'; @@ -849,12 +1271,18 @@ class DownloadManager { } }); + if (unregisteredServerTasks.length) void this.enqueueServerTasks(unregisteredServerTasks); + this.renderList(); this.processQueue(); } clearCompleted() { - this.tasks = this.tasks.filter(t => t.status !== 'finished'); + if (this.tasks.some(t => t.serverManaged && (t.status === 'finished' || t.status === 'exists'))) { + this.requestServerQueue('/api/music/cache/queue/remove', { completed: true }) + .catch(e => console.warn('[DownloadManager] Failed to clear completed server queue tasks:', e)); + } + this.tasks = this.tasks.filter(t => t.status !== 'finished' && t.status !== 'exists'); this.renderList(); this.saveTasks(); } @@ -862,14 +1290,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,10 +1311,13 @@ 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)); + this.requestServerQueue('/api/music/cache/queue/remove', { all: true }) + .catch(err => console.error('[DownloadManager] Failed to clear persistent server queue:', err)); this.tasks = []; this.activeCount = 0; @@ -895,6 +1329,8 @@ class DownloadManager { this.tasks.forEach(t => { if (t.status === 'downloading' && t.controller) t.controller.abort(); }); + this.requestServerQueue('/api/music/cache/queue/remove', { all: true }) + .catch(err => console.error('[DownloadManager] Failed to clear persistent server queue:', err)); this.tasks = []; this.activeCount = 0; this.renderList(); @@ -906,16 +1342,28 @@ class DownloadManager { saveTasks() { try { // Serialize only the data we need, not the AbortController - const data = this.tasks.map(t => ({ + // Server-managed tasks are persisted by the backend and restored through + // /api/music/cache/queue. Keep sessionStorage for browser downloads only. + const data = this.tasks.filter(t => !t.serverManaged).map(t => ({ id: t.id, - song: t.song, + song: this.getSongInfoForStorage(t.song), isServer: t.isServer, + useNativeDownload: !!t.useNativeDownload, + nativeDownloadDispatched: !!t.nativeDownloadDispatched, 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 + : (['waiting', 'starting', 'downloading', 'tagging'].includes(t.status) ? 'paused' : 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 + maxRetries: t.maxRetries || 2, + hasLyric: t.hasLyric === 'checking' ? undefined : t.hasLyric, + lyricRetryCount: t.lyricRetryCount || 0 })); sessionStorage.setItem('lx_download_tasks', JSON.stringify(data)); } catch (e) { @@ -932,20 +1380,33 @@ class DownloadManager { if (!Array.isArray(data) || data.length === 0) return; data.forEach(t => { + const restoredStatus = !t.isServer && ['waiting', 'starting', 'downloading', 'tagging'].includes(t.status) + ? 'paused' + : t.status; 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, + // Migrate server tasks left by older versions without allowing + // the browser scheduler to start a duplicate download. + serverManaged: !!t.isServer, + serverQueueRegistered: false, + serverQueueId: t.isServer ? t.id : null, + useNativeDownload: !t.isServer && (t.useNativeDownload !== false), + nativeDownloadDispatched: !!t.nativeDownloadDispatched, + serverSongKey: t.serverSongKey || '', quality: t.quality || '', // Local downloading → reset to waiting to re-download; server/finished → keep status - status: t.status, + status: restoredStatus, 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, + hasLyric: t.hasLyric === 'checking' ? undefined : t.hasLyric, + lyricRetryCount: t.lyricRetryCount || 0, controller: null }); }); @@ -967,16 +1428,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++; } }); @@ -1023,16 +1484,20 @@ class DownloadManager { const hasRealProgress = isServerTask && (task.totalBytes > 0 || task.progress > 0); statusText = isServerTask ? (hasRealProgress ? `云端 ${progressWidth}%` : '云端下载中') - : `${progressWidth}%`; - speedText = task.speed > 0 ? `${this.formatSize(task.speed)}/s` : ''; + : (task.nativeDownloadDispatched ? `浏览器 ${progressWidth}%` : `${progressWidth}%`); + speedText = `${this.formatSize(Math.max(0, task.speed || 0))}/s`; - if (!isServerTask) { + if (!isServerTask && !task.nativeDownloadDispatched) { actionBtnHTML = ` `; } + } 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 +1611,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 = {