From b93c2ee0163c643f7af893565d5193b9b87187b7 Mon Sep 17 00:00:00 2001 From: v Date: Mon, 10 Aug 2026 16:07:16 +0800 Subject: [PATCH] feat: improve Subsonic and web playlist workflows --- README.md | 16 +- changelog.md | 26 ++ config.js | 12 +- docs/en/guide/configuration.md | 4 +- docs/guide/configuration.md | 4 +- package.json | 1 + public/app.js | 8 +- public/index.html | 9 +- public/music/app.js | 362 +++++++++++++--- public/music/index.html | 71 +++- public/music/js/batch_pagination.js | 55 +-- public/music/js/common_ui.js | 4 +- public/music/js/download_manager.js | 12 +- public/music/js/leaderboard_manager.js | 53 ++- public/music/js/local_music.js | 63 ++- public/music/js/single_song_ops.js | 52 +-- public/music/js/songlist_manager.js | 41 +- src/defaultConfig.ts | 4 +- src/server/server.ts | 29 +- src/server/subsonic.ts | 246 +++++++++-- test/regressions.test.cjs | 552 +++++++++++++++++++++++++ 21 files changed, 1391 insertions(+), 233 deletions(-) create mode 100644 test/regressions.test.cjs diff --git a/README.md b/README.md index 9784b1b..0623ee6 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ ### 10. Subsonic 协议与全网检索支持 -全面适配 Subsonic 协议,支持使用各类 Subsonic 客户端(如音流、Feishin 等)连接并播放本站资源。支持在 Subsonic 客户端中通过 `wy:`, `kg:`, `tx:`, `kw:`, `mg:` 等指定平台前缀,或 `online:` / `local:` 强制指定全网在线或本地搜索。 +全面适配 Subsonic 协议,支持使用各类 Subsonic 客户端(如音流、Feishin 等)连接并播放本站资源。可通过客户端的音乐目录选择本地、全部在线或指定平台,也可在关键词前使用 `wy:`、`kg:`、`tx:`、`kw:`、`mg:` 指定平台,使用 `all:` / `online:` 或 `local:` 强制全部在线或本地搜索。

Subsonic 支持 @@ -210,8 +210,8 @@ services: # - FRONTEND_PASSWORD=123456 # - ENABLE_WEBPLAYER_AUTH=true # - WEBPLAYER_PASSWORD=yourpassword - # - ADMIN_PATH= - # - PLAYER_PATH=/music + # - ADMIN_PATH=/music + # - PLAYER_PATH=/ ``` ### 方式三:直接运行 (Git Clone) @@ -235,8 +235,8 @@ npm start ### 3. 访问说明 -- **Web 播放器**: `http://your-ip:9527` (默认根路径,可通过 `PLAYER_PATH` 修改) -- **同步管理后台**: `http://your-ip:9527/admin` (默认路径 `/admin`,可通过 `ADMIN_PATH` 修改,默认密码: `123456`) +- **Web 播放器**: `http://your-ip:9527` (默认路径,可通过 `PLAYER_PATH` 修改) +- **同步管理后台**: `http://your-ip:9527/music` (默认路径,可通过 `ADMIN_PATH` 修改,默认密码: `123456`) --- @@ -246,7 +246,7 @@ npm start - **Backend (Express + WebSocket)**: 核心同步逻辑与 WebDAV 备份。 - **WebPlayer (Vanilla JS)**: 负责音乐播放业务,默认访问路径为根路径 `/`。 -- **Console (Vanilla JS)**: 位于 `/admin` 路径,负责用户与数据管理。 +- **Console (Vanilla JS)**: 位于 `/music` 路径,负责用户与数据管理。 --- @@ -258,8 +258,8 @@ npm start | --------------------------------------- | ------------------------------------ | ------------------------------------------------------------------ | ------------------ | | `PORT` | `port` | 服务端口 | `9527` | | `BIND_IP` | `bindIP` | 绑定 IP | `0.0.0.0` | -| `ADMIN_PATH` | `admin.path` | 后台管理界面访问路径 (默认为 `/admin`) | `/admin` | -| `PLAYER_PATH` | `player.path` | Web 播放器访问路径 (默认为空,即根路径 `/`) | (空) | +| `ADMIN_PATH` | `admin.path` | 后台管理界面访问路径 | `/music` | +| `PLAYER_PATH` | `player.path` | Web 播放器访问路径 (默认为根路径 `/`) | `/` | | `SUBSONIC_ENABLE` | `subsonic.enable` | 是否启用 Subsonic 协议支持 (服务默认开启) | `true` | | `SUBSONIC_PATH` | `subsonic.path` | Subsonic 访问路径 (默认为 `/rest`) | `/rest` | | `FRONTEND_PASSWORD` | `frontend.password` | Web 管理界面访问密码 | `123456` | diff --git a/changelog.md b/changelog.md index 386be08..1c1d7a0 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,22 @@ ## v2.0.1 (2026-07-23) +### 🌟 新增功能 + +- **Subsonic 歌单管理接口补全 (#311)**: + - **歌单增删改支持**: 实现 `createPlaylist`、`deletePlaylist`,并补全 `updatePlaylist` 的歌曲添加、删除、改名及位置调整能力;兼容重复参数、逗号分隔参数与 `songIdToAdd`、`songIndexToAdd` 等常用客户端调用方式。 + - **数据持久化与添加位置**: Subsonic 歌单变更后自动创建快照,并统一遵循用户的 `LIST_ADD_MUSIC_LOCATION_TYPE` 配置。 +- **Subsonic 多平台搜索增强**: + - **搜索范围选择**: 音乐目录新增本地曲库、全部在线平台及网易云、QQ、酷我、酷狗、咪咕等独立平台入口;关键词支持 `all:` / `online:`、`local:` 及各平台前缀搜索。 + - **稳定分页结果**: 对多平台结果进行交错合并与短期缓存,避免客户端翻页时出现重复、跳项或搜索结果不全。 +- **Web 播放器歌单操作增强**: + - **全列表快捷收藏**: 搜索结果、收藏歌曲、歌单详情、排行榜、本地音乐、缓存与下载任务、歌手及专辑歌曲等位置均可直接添加到歌单。 + - **加入状态预检查**: 打开歌单选择器时先检查歌曲是否已存在;未加入的歌单保持默认绿色样式,已存在的歌单显示红色对号,添加后保持当前列表页码。 + - **歌单导出**: 支持将默认列表、收藏列表及自定义歌单导出为本地 JSON 文件。 +- **列表分页与后台入口**: + - **首页与末页快捷跳转**: 搜索、收藏、歌单广场、排行榜、本地音乐及歌手歌曲列表新增首页和末页按钮,并根据总页数更新可用状态。 + - **后台快捷菜单**: Web 播放器侧边栏新增后台管理入口。 + ### 🔧 修复与优化 - **非管理员下载与缓存权限独立控制 (#284)**: @@ -17,6 +33,16 @@ - **下拉选择器布局修复**: 「歌词字体」选择器与「读取」按钮、「播放失败策略优先级」等下拉项改用 `flex items-center justify-between`,手机和电脑上按钮均固定显示在下拉框右侧,不再单独折行。 - **设置面板底部安全距离**: 系统、显示、逻辑三个设置 Tab 页底部新增移动端安全间距,防止最后一项内容被手机浏览器底部导航栏遮挡,确保「后台管理」等按钮可正常点击。 - **弹出提示气泡防溢出**: 设置项问号图标的悬浮提示气泡新增视口边缘检测,显示时自动计算并修正横向偏移,防止气泡内容超出屏幕左右边缘显示不全。 +- **播放失败自动恢复**: + - **异常暂停自动换源**: 修复解析到播放地址后播放器停留在“暂停中”且不继续换源的问题;播放异常暂停或地址加载失败时自动进入恢复流程。 + - **连续换源尝试**: 修复首次跨平台切换失败后恢复流程提前结束的问题,能够继续尝试尚未使用的可用平台。 +- **歌曲添加位置配置修复**: + - **底部添加配置生效**: 修复 `LIST_ADD_MUSIC_LOCATION_TYPE=bottom` 在 Web 播放器及服务端歌单接口中不生效的问题,未显式指定位置时统一读取用户配置。 +- **歌曲删除与分页状态修复**: + - **令牌认证兼容**: 修复已使用令牌登录但未保存明文密码时无法从列表删除歌曲的问题,并在认证失效后自动刷新令牌重试。 + - **保持当前页**: 添加或删除歌曲后刷新当前列表数据,不再自动跳回第一页。 +- **默认访问路径调整**: + - **播放器作为首页**: 默认 `PLAYER_PATH` 调整为 `/`,访问域名直接进入 Web 播放器;后台管理默认迁移到 `/music`,并同步更新管理界面、配置示例及中英文文档。 ## v2.0.0 (2026-07-22) diff --git a/config.js b/config.js index 20ae1dd..453a8b4 100644 --- a/config.js +++ b/config.js @@ -73,7 +73,7 @@ module.exports = { // 环境变量: MAX_SNAPSHOT_NUM "maxSnapshotNum": 10, - // 添加歌曲到列表时的位置 (top: 顶部, bottom: 底部) + // 添加歌曲到列表时的位置,Web 播放器与 Subsonic API 均使用此配置 (top: 顶部, bottom: 底部) // 环境变量: LIST_ADD_MUSIC_LOCATION_TYPE "list.addMusicLocationType": "top", @@ -144,13 +144,13 @@ module.exports = { // 环境变量: PROXY_ALL_ADDRESS (例如: http://127.0.0.1:7890) "proxy.all.address": "", - // 后台管理界面访问路径(默认为 /admin) + // 后台管理界面访问路径(默认为 /music) // 环境变量: ADMIN_PATH - "admin.path": "/admin", + "admin.path": "/music", - // Web播放器访问路径(默认为空,即根路径 /) + // Web播放器访问路径(默认为根路径 /) // 环境变量: PLAYER_PATH - "player.path": "", + "player.path": "/", // Subsonic 协议配置 // 是否启用 Subsonic 协议支持 (服务默认开启) @@ -199,4 +199,4 @@ module.exports = { // 是否允许运行 VM 模式自定义源脚本 (默认关闭) // 环境变量: SYSTEM_ALLOW_UNSAFE_VM "system.allowUnsafeVM": false -} \ No newline at end of file +} diff --git a/docs/en/guide/configuration.md b/docs/en/guide/configuration.md index 97394fa..06c039a 100644 --- a/docs/en/guide/configuration.md +++ b/docs/en/guide/configuration.md @@ -28,8 +28,8 @@ This module manages the Node.js listening process and the basic settings of the | :-------------------- | :------------ | :------- | :-------------------------------------------------------------------------------------------------------------------------------------------------- | | `PORT` | `9527` | Integer | **Service listening port**. It is recommended to avoid using other high-frequency ports in the host (such as 80, 443, 3306). | | `BIND_IP` | `0.0.0.0` | String | **Scope of service binding IP interfaces**. Set to `127.0.0.1` to accept only local Lookback calls; set to `0.0.0.0` means listening to all internal and external available network adapters of the host simultaneously. | -| `ADMIN_PATH` | `'/admin'` | String | **Backend management interface path**. Default is `/admin`. | -| `PLAYER_PATH` | `''` | String | **Web player access path**. Default is empty, i.e., root `/`. | +| `ADMIN_PATH` | `'/music'` | String | **Backend management interface path**. Default is `/music`. | +| `PLAYER_PATH` | `'/'` | String | **Web player access path**. Default is the root path `/`. | | `SERVER_NAME` | `My Sync Server` | String | **Sync service name**. Showed in client connections. | | `PROXY_HEADER` | `x-real-ip` | String | **Reverse proxy remote IP penetration identifier**. When the system runs behind reverse proxies or load balancers such as Nginx, it is used to extract the true client source IP address to ensure accurate traceability of equipment audit logs. | | `PROXY_ALL_ENABLED` | `false` | Boolean | **Enable global outgoing request proxy**. If enabled, network requests from the server (e.g. search, resolving) will go through the proxy. | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 944d053..57e2e03 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -28,8 +28,8 @@ LX Music Sync Server 构建了统一的基础模型骨架(位于 `src/defaultC | :-------------------- | :------------ | :------- | :-------------------------------------------------------------------------------------------------------------------------------------------------- | | `PORT` | `9527` | Integer | **服务监听端口**。建议避免使用主机中其他高频占用的端口(如 80、443、3306)。 | | `BIND_IP` | `0.0.0.0` | String | **服务绑定的 IP 接口范围**。设定为 `127.0.0.1` 仅接受本机 Lookback 调用;设定为 `0.0.0.0` 意味着同时监听主机所有内外部可用网络适配器。 | -| `ADMIN_PATH` | `'/admin'` | String | **后台管理界面访问路径**。默认为 `/admin`。 | -| `PLAYER_PATH` | `''` | String | **Web 播放器访问路径**。默认为空(即根路径 `/`)。 | +| `ADMIN_PATH` | `'/music'` | String | **后台管理界面访问路径**。默认为 `/music`。 | +| `PLAYER_PATH` | `'/'` | String | **Web 播放器访问路径**。默认为根路径 `/`。 | | `SERVER_NAME` | `My Sync Server` | String | **同步服务名称**。在客户端连接时显示的服务器标识名称。 | | `PROXY_HEADER` | `x-real-ip` | String | **逆向代理远端 IP 穿透标识**。当系统运行于 Nginx 等反向代理或负载均衡器后方时,用于提取客户端真实的源端 IP 地址,保障设备审计日志的准确溯源。 | | `PROXY_ALL_ENABLED` | `false` | Boolean | **启用全局外发请求代理**。开启后,服务端发起的网络请求(如搜索、播放链接解析)将通过指定的代理服务器。 | diff --git a/package.json b/package.json index 8f8cd7c..2b338ce 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "prebuild": "node scripts/download-binary.js && node scripts/update-build-hash.js", "build": "rimraf server && tsc --project tsconfig.json && tsc-alias -p tsconfig.json", + "test": "node --test test/*.test.cjs", "prestart": "node scripts/download-binary.js && node scripts/update-build-hash.js", "start": "node ./index.js", "predev": "node scripts/update-build-hash.js", diff --git a/public/app.js b/public/app.js index 612a2c7..986de35 100644 --- a/public/app.js +++ b/public/app.js @@ -319,7 +319,7 @@ class App { window.location.href = 'filemanager.html'; return; case 'music': - window.location.href = (window.CONFIG && window.CONFIG['player.path']) || '/music'; + window.location.href = (window.CONFIG && window.CONFIG['player.path']) || '/'; return; } } @@ -1785,13 +1785,13 @@ class App { form.elements['admin.path'].value = config['admin.path'] ?? ''; } if (form.elements['player.path']) { - const pPath = config['player.path'] ?? '/music'; + const pPath = config['player.path'] ?? '/'; form.elements['player.path'].value = pPath === '' ? '/' : pPath; } // [新增] 同时更新侧边栏链接 const navPlayerLink = document.getElementById('nav-player-link'); - if (navPlayerLink) navPlayerLink.href = (config['player.path'] === '' ? '/' : (config['player.path'] ?? '/music')); + if (navPlayerLink) navPlayerLink.href = (config['player.path'] === '' ? '/' : (config['player.path'] ?? '/')); // Subsonic 配置 if (form.elements['subsonic.enable']) { @@ -1922,7 +1922,7 @@ class App { // 更新侧边栏播放器链接 const navPlayerLink = document.getElementById('nav-player-link'); - if (navPlayerLink) navPlayerLink.href = playerPath === '' ? '/' : (playerPath ?? '/music'); + if (navPlayerLink) navPlayerLink.href = playerPath === '' ? '/' : (playerPath ?? '/'); if (!silent) { if (res.warning) { diff --git a/public/index.html b/public/index.html index 2e19c6d..9af1554 100644 --- a/public/index.html +++ b/public/index.html @@ -1044,8 +1044,11 @@

Subsonic 协议配置

- 💡 客户端搜索与歌词提示:同时间戳歌词顺序为原文在上,翻译在下
- • wy:歌名 / tx:歌名 ➔ 客户端搜索框输入前缀可强制在线搜索平台 + 💡 Subsonic 客户端搜索:直接输入歌曲名时使用上方“默认在线搜索模式”。也可在搜索框中使用以下前缀:
+ • all:歌名 / online:歌名:搜索全部已启用在线平台
+ • local:歌名:只搜索本地歌单
+ • wy:歌名tx:歌名kw:歌名kg:歌名mg:歌名:指定网易、QQ、酷我、酷狗或咪咕
+ 客户端支持音乐目录时,也可选择“全部在线平台”或单个平台目录后再搜索。同时间戳歌词顺序为原文在上,翻译在下
@@ -1405,4 +1408,4 @@

修改用户名

- \ No newline at end of file + diff --git a/public/music/app.js b/public/music/app.js index 2b6eedf..d7e0274 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -34,6 +34,7 @@ let currentPlayingScope = 'network'; // Scope for active playback window.currentSearchScope = 'network'; // 'network', 'local_list', 'local_all' - Scope for UI view let currentPlayingSong = null; // Track currently playing song independently of view window.batchCollectSongs = null; // Store songs for batch collection modal +window.playlistAddTargetSong = null; // Explicit single-song target from any song list const audio = document.getElementById('audio-player'); let currentPlaybackRate = 1.0; @@ -155,6 +156,24 @@ let currentRawKlrc = ''; // 逐词歌词 (klyric/lxlyric) let lastLyricSongId = null; // 追踪上次加载歌词的歌曲ID let currentRecoveryState = null; // 播放失败自动恢复状态管理 +let currentPlaybackErrorHandler = null; // 当前音频地址的错误恢复监听器 +let shouldAutoRecoverPlayback = false; +let playbackRecoveryTriggeredForRequestId = 0; + +function handleUnexpectedPlaybackPause() { + const requestId = currentRecoveryState?.thisRequestId; + if (!shouldAutoRecoverPlayback || !requestId || audio.ended || !audio.src) return false; + if (playbackRecoveryTriggeredForRequestId === requestId) return false; + + shouldAutoRecoverPlayback = false; + playbackRecoveryTriggeredForRequestId = requestId; + if (currentPlaybackErrorHandler && typeof audio.removeEventListener === 'function') { + audio.removeEventListener('error', currentPlaybackErrorHandler); + currentPlaybackErrorHandler = null; + } + void runRecoveryFlow(new Error('播放链接意外暂停')); + return true; +} // 从 localStorage 加载设置 try { @@ -696,6 +715,7 @@ async function handleLogout() { try { if (typeof audio !== 'undefined' && audio) { + shouldAutoRecoverPlayback = false; audio.pause(); audio.currentTime = 0; audio.src = ''; @@ -714,7 +734,7 @@ async function handleLogout() { sessionStorage.clear(); if (agreementAccepted) localStorage.setItem('lx_agreement_accepted', agreementAccepted); - const playerPath = (window.CONFIG && window.CONFIG['player.path']) || (window.lx_config && window.lx_config['player.path']) || '/music'; + const playerPath = (window.CONFIG && window.CONFIG['player.path']) || (window.lx_config && window.lx_config['player.path']) || '/'; const normalizedPlayerPath = (playerPath === '/' || playerPath === '') ? '' : playerPath.replace(/\/+$/, ''); window.location.replace(`${normalizedPlayerPath}/login`); } @@ -1323,7 +1343,10 @@ function renderQueue() {
-
+
+ @@ -1411,6 +1434,7 @@ function removeFromQueue(index) { // Typically people expect it to stay at same index but if it was last, wrap around if (currentPlaylist.length === 0) { currentIndex = -1; + shouldAutoRecoverPlayback = false; try { audio.pause(); } catch (e) { } } else if (currentIndex >= currentPlaylist.length) { currentIndex = 0; // Wrap to start @@ -1433,6 +1457,7 @@ async function clearQueue() { if (await showSelect('清空队列', '确定要清空当前播放队列吗?', { danger: true })) { currentPlaylist = []; currentIndex = -1; + shouldAutoRecoverPlayback = false; try { audio.pause(); } catch (e) { } renderQueue(); savePlaybackState(); // Save empty state @@ -2460,12 +2485,15 @@ function renderArtistSongsUI(list, page) {
- - +
`; @@ -2473,7 +2501,11 @@ function renderArtistSongsUI(list, page) {
-
+
+ +
`; content.innerHTML = html; @@ -2499,6 +2535,10 @@ function artistSongsPrevPage() { if (!window.artistSongsPage || window.artistSongsPage <= 1) return; renderArtistSongsUI(list, window.artistSongsPage - 1); } +function artistSongsFirstPage() { + const list = window.currentArtistSongsCache; + if (list && (window.artistSongsPage || 1) !== 1) renderArtistSongsUI(list, 1); +} function artistSongsNextPage() { const list = window.currentArtistSongsCache; if (!list) return; @@ -2509,8 +2549,18 @@ function artistSongsNextPage() { if ((window.artistSongsPage || 1) >= totalPages) return; renderArtistSongsUI(list, (window.artistSongsPage || 1) + 1); } +function artistSongsLastPage() { + const list = window.currentArtistSongsCache; + if (!list) return; + const totalItems = list.length; + let itemsPerPage = (settings && settings.itemsPerPage === 'all') ? totalItems : parseInt((settings && settings.itemsPerPage) || 20); + if (!itemsPerPage || itemsPerPage <= 0) itemsPerPage = 20; + renderArtistSongsUI(list, Math.max(1, Math.ceil(totalItems / itemsPerPage))); +} window.artistSongsPrevPage = artistSongsPrevPage; window.artistSongsNextPage = artistSongsNextPage; +window.artistSongsFirstPage = artistSongsFirstPage; +window.artistSongsLastPage = artistSongsLastPage; const ARTIST_ALBUM_PAGE_SIZE = 50; const ARTIST_ALBUM_MAX_PAGES = 100; @@ -3037,16 +3087,34 @@ function renderResults(list) { onclick="event.stopPropagation(); downloadSong(${JSON.stringify(item).replace(/"/g, '"')})"> + ${currentSearchScope !== 'network' ? ` - ` : ''}
`; + const addToPlaylistBtn = row.querySelector('.add-to-playlist-btn'); + if (addToPlaylistBtn) { + addToPlaylistBtn.onclick = (event) => { + event.stopPropagation(); + openPlaylistAddModalForSong(actualIndexInOriginal); + }; + } + const deleteSongBtn = row.querySelector('.delete-song-btn'); + if (deleteSongBtn) { + deleteSongBtn.onclick = (event) => { + event.stopPropagation(); + deleteSingleSong(String(item.id)); + }; + } + container.appendChild(row); }); @@ -4258,26 +4326,21 @@ async function runRecoveryFlow(error) { await runRecoveryFlow(error); } } else if (currentStep === 'switch_platform') { - if (currentRecoveryState.currentSong === currentRecoveryState.originalSong) { - showInfo('正在自动尝试换源匹配...'); - const matchedSong = await findOtherSourceMatch(currentRecoveryState.originalSong); - if (matchedSong) { - currentRecoveryState.currentSong = matchedSong; - currentRecoveryState.triedPlatforms.push(matchedSong.source); - const bestNextQuality = window.QualityManager.getBestQuality(matchedSong, settings.preferredQuality || 'flac'); - currentRecoveryState.currentQuality = bestNextQuality; - currentRecoveryState.triedQualities = [bestNextQuality]; - - showInfo(`找到备选源,尝试从 ${getSourceName(matchedSong.source)} 播放...`); - // Re-invoke playSong with isRetry = true - playSong(matchedSong, currentRecoveryState.currentIndex, bestNextQuality, false, true); - } else { - // No match found, move to next recovery step - currentRecoveryState.currentStepIndex++; - await runRecoveryFlow(error); - } + showInfo('正在自动尝试换源匹配...'); + const matches = await findOtherSourceMatches(currentRecoveryState.originalSong); + const matchedSong = matches.find(song => !currentRecoveryState.triedPlatforms.includes(song.source)); + if (matchedSong) { + currentRecoveryState.currentSong = matchedSong; + currentRecoveryState.triedPlatforms.push(matchedSong.source); + const bestNextQuality = window.QualityManager.getBestQuality(matchedSong, settings.preferredQuality || 'flac'); + currentRecoveryState.currentQuality = bestNextQuality; + currentRecoveryState.triedQualities = [bestNextQuality]; + + showInfo(`找到备选源,尝试从 ${getSourceName(matchedSong.source)} 播放...`); + // Keep this recovery step active so another platform can be tried if needed. + playSong(matchedSong, currentRecoveryState.currentIndex, bestNextQuality, false, true); } else { - // Already switched once, move to next recovery step + // No untried source remains, move to the next recovery strategy. currentRecoveryState.currentStepIndex++; await runRecoveryFlow(error); } @@ -4311,6 +4374,13 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr const thisRequestId = ++loadingRequestCounter; currentLoadingSongId = thisRequestSongId; currentLoadingRequestId = thisRequestId; + shouldAutoRecoverPlayback = false; + playbackRecoveryTriggeredForRequestId = 0; + + if (currentPlaybackErrorHandler) { + audio.removeEventListener('error', currentPlaybackErrorHandler); + currentPlaybackErrorHandler = null; + } if (!isRetry) { const order = (settings.playbackErrorPriority || 'platform,quality,next').split(','); @@ -4474,7 +4544,12 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr if (playbackSong !== song) { currentPlayingSong = playbackSong; window.currentPlayingSong = playbackSong; - if (currentRecoveryState) currentRecoveryState.currentSong = playbackSong; + if (currentRecoveryState) { + currentRecoveryState.currentSong = playbackSong; + if (playbackSong.source && !currentRecoveryState.triedPlatforms.includes(playbackSong.source)) { + currentRecoveryState.triedPlatforms.push(playbackSong.source); + } + } updateMediaSessionMetadata(playbackSong); fetchLyric(playbackSong, currentQuality); } @@ -4502,18 +4577,26 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr // [Removed] 这里的代理逻辑已统一移动至 fetchSongUrl 阶段处理,确保预加载地址一致性 - // Pre-handle error for invalid cache links - if (currentSourceType !== 'normal') { - const retryHandler = () => { + // Media errors happen asynchronously after play() resolves, so every source + // needs an explicit recovery entry point. + currentPlaybackErrorHandler = () => { + if (!currentRecoveryState || currentRecoveryState.thisRequestId !== thisRequestId) return; + currentPlaybackErrorHandler = null; + + if (currentSourceType !== 'normal') { + shouldAutoRecoverPlayback = false; console.warn(`[Player] ${currentSourceType} link failed, retrying online...`); 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); - audio.addEventListener('playing', cleanup, { once: true }); - audio.addEventListener('pause', cleanup, { once: true }); - } + return; + } + + playbackRecoveryTriggeredForRequestId = thisRequestId; + shouldAutoRecoverPlayback = false; + const mediaError = audio.error || new Error('媒体播放失败'); + void runRecoveryFlow(mediaError); + }; + audio.addEventListener('error', currentPlaybackErrorHandler, { once: true }); audio.src = finalUrl; @@ -4533,6 +4616,7 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr if (settings.enableCrossfade) audio.volume = 0; else audio.volume = typeof currentVolume !== 'undefined' ? currentVolume : 1; + shouldAutoRecoverPlayback = true; await audio.play(); if (settings.enableCrossfade) fadeVolume(typeof currentVolume !== 'undefined' ? currentVolume : 1, 1000); @@ -4571,13 +4655,26 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr } } } catch (playError) { - // [Fix] 仅在请求仍有效且非 AbortError 时显示“请点击”提示,防止切歌太快导致旧请求的错误覆盖新请求的新状态 + // Only browser autoplay blocking needs a manual click. Decode, network, + // and unsupported-source failures should continue through auto recovery. if (currentLoadingRequestId !== thisRequestId) return; const isAbort = playError && (playError.name === 'AbortError' || playError.code === 20); - if (isAbort) return; + if (isAbort) { + shouldAutoRecoverPlayback = false; + return; + } - console.error('[Player] Playback blocked:', playError); - setPlayerStatus('请点击播放按钮'); + const isAutoplayBlocked = playError && playError.name === 'NotAllowedError'; + if (isAutoplayBlocked) { + shouldAutoRecoverPlayback = false; + console.warn('[Player] Playback blocked by browser autoplay policy:', playError); + setPlayerStatus('请点击播放按钮'); + } else if (currentRecoveryState && currentRecoveryState.thisRequestId === thisRequestId) { + shouldAutoRecoverPlayback = false; + playbackRecoveryTriggeredForRequestId = thisRequestId; + console.error('[Player] Playback failed:', playError); + await runRecoveryFlow(playError); + } } // [Trigger Prefetch] 确保即便 play() 被拦截也尝试发起下一首预读 @@ -4679,6 +4776,20 @@ function savePlayHistory(song, quality) { } } +function getConfiguredAddMusicLocationType() { + return window.CONFIG && window.CONFIG['list.addMusicLocationType'] === 'bottom' ? 'bottom' : 'top'; +} + +function addMusicByConfiguredLocation(list, music) { + const addMusicLocationType = getConfiguredAddMusicLocationType(); + if (addMusicLocationType === 'bottom') { + list.push(music); + } else { + list.unshift(music); + } + return addMusicLocationType; +} + // 添加到默认列表 (试听列表) async function addToDefaultList(song) { if (!currentListData || !currentListData.defaultList) return; @@ -4696,12 +4807,15 @@ async function addToDefaultList(song) { list.splice(idx, 1); } - // Add to top - list.unshift(cleanedData); + const addMusicLocationType = addMusicByConfiguredLocation(list, cleanedData); // Limit size to avoid bloat (e.g., 200 songs) if (list.length > 200) { - list.length = 200; + if (addMusicLocationType === 'bottom') { + list.splice(0, list.length - 200); + } else { + list.length = 200; + } } // Sync @@ -4991,6 +5105,7 @@ async function togglePlay() { if (settings.enableCrossfade) { audio.volume = 0; } + shouldAutoRecoverPlayback = true; await audio.play(); updatePlayButton(true); @@ -4998,6 +5113,7 @@ async function togglePlay() { fadeVolume(typeof currentVolume !== 'undefined' ? currentVolume : 1, 600); } } catch (e) { + shouldAutoRecoverPlayback = false; console.error("[Player] Play blocked:", e); } } else { @@ -5005,6 +5121,7 @@ async function togglePlay() { if (settings.enableCrossfade) { await fadeVolume(0, 600); } + shouldAutoRecoverPlayback = false; audio.pause(); if (window._autoSkipTimer) { clearTimeout(window._autoSkipTimer); @@ -5223,6 +5340,7 @@ audio.addEventListener('pause', () => { // [Fix] 这里的状态更新确保 UI 与实际播放状态同步 setPlayerStatus('', false); // 使用智能状态显示 updatePlayButton(false); + handleUnexpectedPlaybackPause(); if (lyricPlayer) { lyricPlayer.pause(); @@ -5381,6 +5499,7 @@ window.updatePositionState = updatePositionState; // 暴露给保活模块调用 // 歌曲播放结束时根据播放模式处理 audio.addEventListener('ended', () => { + shouldAutoRecoverPlayback = false; playNext(); }); @@ -6574,8 +6693,12 @@ function renderCacheList() { `} -
+
${!cacheBatchMode ? ` + ${typeof listObj !== 'string' ? `` : ''} ${id !== 'default' && id !== 'love' ? `` : ''} `; @@ -9460,7 +9643,7 @@ function renderMyLists(data) { } } -function handleListClick(listId, skipAutoUpdate = false) { +function handleListClick(listId, skipAutoUpdate = false, preservePage = false) { exitListSecondaryModes(); if (!currentListData) return; @@ -9550,7 +9733,10 @@ function handleListClick(listId, skipAutoUpdate = false) { } // Render - currentPage = 1; // Reset pagination + if (!preservePage) { + currentPage = 1; + window.currentPage = 1; + } renderResults(list); // [New] Auto Update Logic: If it's a network playlist (has sourceListId) and setting is ON, refresh background @@ -9834,7 +10020,7 @@ async function toggleLove() { if (index >= 0) { activeListData.loveList.splice(index, 1); } else { - activeListData.loveList.push(formattedSong); + addMusicByConfiguredLocation(activeListData.loveList, formattedSong); } updatePlayerInfo(song); @@ -10145,6 +10331,7 @@ async function refreshUserListData() { if (!window.SyncManager) return; try { const listData = await window.SyncManager.sync(); + currentListData = listData; window.currentListData = listData; if (listData && listData.username !== '_open') { window.myPersonalListData = listData; @@ -10156,14 +10343,16 @@ async function refreshUserListData() { // [New] If currently viewing a local list, refresh its contents in main view if (window.currentSearchScope === 'local_list' && window.currentViewingListId) { console.log('[Sync] Auto-refreshing current list view:', window.currentViewingListId); - handleListClick(window.currentViewingListId, true); // true to skip background auto-update + handleListClick(window.currentViewingListId, true, true); } // Save to cache await window.ListStore.set(listData).catch(e => console.error('[IDBStore] 保存失败:', e)); console.log('[Sync] List Data Refreshed'); + return listData; } catch (e) { console.error('[Sync] Failed to refresh list data:', e); + return null; } } @@ -10173,6 +10362,7 @@ window.handleCreateList = handleCreateList; window.handleRenameList = handleRenameList; window.handleRefreshList = handleRefreshList; window.handleRemoveList = handleRemoveList; +window.exportPlaylistToLocal = exportPlaylistToLocal; window.toggleFavorites = toggleFavorites; window.handleFavoritesClick = handleFavoritesClick; window.handleRemoteStep1 = handleRemoteStep1; @@ -10910,7 +11100,7 @@ function closeCustomSourceModal() { // Helper to render the grid (can be called from anywhere) function renderPlaylistAddGrid() { const isBatch = !!window.batchCollectSongs; - const songs = isBatch ? window.batchCollectSongs : [currentPlayingSong]; + const songs = isBatch ? window.batchCollectSongs : [window.playlistAddTargetSong || currentPlayingSong]; const firstSong = songs[0]; if (!firstSong) return; @@ -10934,7 +11124,7 @@ function renderPlaylistAddGrid() { // Active/Inactive styles (Highlight only in single-song mode) if (!isBatch && isIncluded) { - className += "bg-emerald-500 text-white shadow-md scale-[1.02] ring-2 ring-emerald-200"; + className += "bg-red-500 text-white shadow-md scale-[1.02] ring-2 ring-red-200"; } else { className += "bg-emerald-50 text-emerald-500 hover:bg-emerald-100 hover:shadow"; } @@ -10976,9 +11166,30 @@ function renderPlaylistAddGrid() { listContainer.appendChild(createNewBtn); } +function hasPlaylistData(data) { + return !!data && Array.isArray(data.loveList) && Array.isArray(data.userList) && + data.userList.every(list => list && Array.isArray(list.list)); +} + +async function ensurePlaylistDataAvailable() { + const getActiveData = () => isUserLoggedIn() + ? (window.myPersonalListData || currentListData) + : currentListData; + + let activeListData = getActiveData(); + if (hasPlaylistData(activeListData)) return activeListData; + + if (window.SyncManager?.client && typeof refreshUserListData === 'function') { + await refreshUserListData(); + activeListData = getActiveData(); + } + return hasPlaylistData(activeListData) ? activeListData : null; +} + async function openPlaylistAddModal(batchSongs = null) { - if (!currentListData) { - showError('请先登录后使用收藏功能'); + const activeListData = await ensurePlaylistDataAvailable(); + if (!activeListData) { + showError('未找到可用歌单,请先登录或创建歌单'); return; } @@ -10994,14 +11205,22 @@ async function openPlaylistAddModal(batchSongs = null) { if (collectableSongs.length === 0) { showError('歌曲不在曲库中,无法收藏到歌单。请先使用“手动关联”绑定平台歌曲 ID。'); window.batchCollectSongs = null; + window.playlistAddTargetSong = null; return; } if (unavailableCount > 0) { showInfo(`已跳过 ${unavailableCount} 首未绑定平台 ID 的歌曲;歌曲不在曲库中,无法收藏到歌单。`); } - window.batchCollectSongs = collectableSongs; + if (collectableSongs.length === 1) { + window.batchCollectSongs = null; + window.playlistAddTargetSong = collectableSongs[0]; + } else { + window.batchCollectSongs = collectableSongs; + window.playlistAddTargetSong = null; + } } else { window.batchCollectSongs = null; + window.playlistAddTargetSong = null; if (isUnboundLocalSong(currentPlayingSong)) { showError('歌曲不在曲库中,无法收藏到歌单。请先使用“手动关联”绑定平台歌曲 ID。'); return; @@ -11009,7 +11228,7 @@ async function openPlaylistAddModal(batchSongs = null) { } const isBatch = !!window.batchCollectSongs; - const song = isBatch ? window.batchCollectSongs[0] : currentPlayingSong; + const song = isBatch ? window.batchCollectSongs[0] : (window.playlistAddTargetSong || currentPlayingSong); if (!song) { showError(isBatch ? '无可收藏的歌曲' : '当前没有正在播放的歌曲'); @@ -11036,6 +11255,26 @@ async function openPlaylistAddModal(batchSongs = null) { }, 10); } +function openPlaylistAddModalForSong(index) { + const song = window.viewingPlaylist?.[index]; + if (!song) { + showError('未找到要添加的歌曲'); + return; + } + openPlaylistAddModal([song]); +} + +function openPlaylistAddModalForSongObject(song) { + if (!song) { + showError('未找到要添加的歌曲'); + return; + } + return openPlaylistAddModal([song]); +} + +window.openPlaylistAddModalForSong = openPlaylistAddModalForSong; +window.openPlaylistAddModalForSongObject = openPlaylistAddModalForSongObject; + function closePlaylistAddModal() { const modal = document.getElementById('playlist-add-modal'); const content = document.getElementById('playlist-add-modal-content'); @@ -11047,6 +11286,8 @@ function closePlaylistAddModal() { setTimeout(() => { if (modal) modal.classList.add('hidden'); + window.batchCollectSongs = null; + window.playlistAddTargetSong = null; // Update Player Info to refresh heart icon state if (currentPlayingSong) { updatePlayerInfo(currentPlayingSong); @@ -11156,7 +11397,7 @@ async function handleTogglePlaylist(listId, btnElement) { if (!(await requireAdminForOpenWrite('修改公开收藏'))) return; } const isBatch = !!window.batchCollectSongs; - const songs = isBatch ? window.batchCollectSongs : [currentPlayingSong]; + const songs = isBatch ? window.batchCollectSongs : [window.playlistAddTargetSong || currentPlayingSong]; if (songs.length === 0 || !songs[0]) return; // --- Batch Mode Logic --- @@ -11179,7 +11420,7 @@ async function handleTogglePlaylist(listId, btnElement) { songs.forEach(s => { const cleaned = cleanSongData(s); if (!targetListArray.some(existing => existing.id === cleaned.id)) { - targetListArray.unshift(cleaned); + addMusicByConfiguredLocation(targetListArray, cleaned); addedSongs.push(cleaned); } }); @@ -11193,7 +11434,7 @@ async function handleTogglePlaylist(listId, btnElement) { // 3. Immediate UI Refresh renderMyLists(currentListData); if (window.currentSearchScope === 'local_list' && window.currentViewingListId) { - handleListClick(window.currentViewingListId, true); + handleListClick(window.currentViewingListId, true, true); } // 4. Close Modal Immediately @@ -11266,7 +11507,7 @@ async function handleTogglePlaylist(listId, btnElement) { try { if (willAdd) { - targetListArray.unshift(cleanedSong); + addMusicByConfiguredLocation(targetListArray, cleanedSong); } else { const idx = targetListArray.findIndex(s => s.id === targetId); if (idx >= 0) targetListArray.splice(idx, 1); @@ -11291,7 +11532,7 @@ function updateGridItemVisuals(btn, isIncluded) { `; } else { - btn.className = "relative h-14 rounded-lg text-sm font-bold transition-all duration-200 flex items-center justify-center gap-1 shadow-sm overflow-hidden bg-red-50 text-red-500 hover:bg-red-100 hover:shadow"; + btn.className = "relative h-14 rounded-lg text-sm font-bold transition-all duration-200 flex items-center justify-center gap-1 shadow-sm overflow-hidden bg-emerald-50 text-emerald-500 hover:bg-emerald-100 hover:shadow"; const textSpan = btn.querySelector('span'); const text = textSpan ? textSpan.innerText : btn.innerText; btn.innerHTML = `${text}`; @@ -12232,6 +12473,7 @@ function finishSleepTimer() { cancelSleepTimer(); // Use audio.pause directly or togglePlay if music is active if (audio && !audio.paused) { + shouldAutoRecoverPlayback = false; audio.pause(); updatePlayButton(false); showInfo('睡眠时间到,音乐已停止播放 🌙'); diff --git a/public/music/index.html b/public/music/index.html index 45c0e7a..d328f46 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -127,6 +127,13 @@

LX MUSIC

设置 +
  • + + + 后台管理 + +
  • @@ -427,11 +434,16 @@

    LX MUSIC

    - +
    + + +
    @@ -451,11 +463,16 @@

    LX MUSIC

    - +
    + + +
  • @@ -541,15 +558,23 @@

    选择分类

    -
    +
    + 第 1 页 +
    @@ -929,6 +954,10 @@

    歌单
    + +
    @@ -1346,6 +1379,10 @@

    本地 diff --git a/public/music/js/batch_pagination.js b/public/music/js/batch_pagination.js index c69a0ce..08f4863 100644 --- a/public/music/js/batch_pagination.js +++ b/public/music/js/batch_pagination.js @@ -186,30 +186,15 @@ async function batchDeleteFromList() { const idsToDelete = Array.from(window.selectedItems); if (window.SyncManager.mode === 'local') { - // Local mode: Use user credentials to directly manipulate data - const username = localStorage.getItem('lx_sync_user'); - const password = localStorage.getItem('lx_sync_pass'); - - if (!username || !password) { + // Token authentication is sufficient; a saved plaintext password is not required. + const authHeaders = getUserAuthHeaders(); + if (!authHeaders['x-user-token'] && !authHeaders['x-user-password']) { showError('请先登录本地账号'); return; } try { - // Call user-specific API endpoint - const res = await fetch('/api/music/user/list/remove', { - method: 'POST', - headers: getUserAuthHeaders(), - body: JSON.stringify({ - listId: activeListId, - songIds: idsToDelete - }) - }); - - if (!res.ok) { - const errorText = await res.text(); - throw new Error(errorText || '删除失败'); - } + await window.requestListSongRemoval(activeListId, idsToDelete); // Reload data from server const data = await window.SyncManager.sync(); @@ -220,7 +205,7 @@ async function batchDeleteFromList() { renderMyLists(data); // Refresh current view - handleListClick(activeListId); + handleListClick(activeListId, true, true); console.log('[Batch] 本地模式删除成功'); @@ -257,7 +242,7 @@ async function batchDeleteFromList() { // Update UI renderMyLists(currentListData); - handleListClick(activeListId); + handleListClick(activeListId, true, true); } catch (e) { showError('批量删除失败: ' + e.message); @@ -328,14 +313,38 @@ function updatePaginationInfo(start, end, total, current, totalPages) { jumpInput.value = pageNum; } } + + const pageNum = current || 1; + const pageCount = totalPages || 1; + const isNetwork = window.currentSearchScope === 'network'; + const firstBtn = document.getElementById('search-btn-first'); + const prevBtn = document.getElementById('search-btn-prev'); + const nextBtn = document.getElementById('search-btn-next'); + const lastBtn = document.getElementById('search-btn-last'); + if (firstBtn) firstBtn.disabled = pageNum <= 1; + if (prevBtn) prevBtn.disabled = pageNum <= 1; + if (nextBtn) nextBtn.disabled = pageNum >= pageCount && !isNetwork; + if (lastBtn) lastBtn.disabled = pageNum >= pageCount; } -function goToPage(page) { +function goToResultPage(page) { currentPage = page; + window.currentPage = page; renderResults(window.viewingPlaylist); scrollToSearchResultsTop(); } +function firstPage() { + if (currentPage !== 1) goToResultPage(1); +} + +function lastPage() { + const totalItems = window.viewingPlaylist ? window.viewingPlaylist.length : 0; + const itemsPerPage = settings.itemsPerPage === 'all' ? totalItems : parseInt(settings.itemsPerPage); + const totalPages = Math.max(1, Math.ceil((totalItems || 1) / (itemsPerPage || 1))); + if (currentPage !== totalPages) goToResultPage(totalPages); +} + async function nextPage() { const totalItems = window.viewingPlaylist ? window.viewingPlaylist.length : 0; const itemsPerPage = settings.itemsPerPage === 'all' ? totalItems : parseInt(settings.itemsPerPage); @@ -346,7 +355,7 @@ async function nextPage() { renderResults(window.viewingPlaylist); scrollToSearchResultsTop(); } else if (window.currentSearchScope === 'network') { - const btn = document.querySelector('button[onclick="nextPage()"]'); + const btn = document.getElementById('search-btn-next'); const oldHtml = btn ? btn.innerHTML : ''; if (btn) { btn.innerHTML = ' 加载中...'; diff --git a/public/music/js/common_ui.js b/public/music/js/common_ui.js index 2b7007f..f8714c2 100644 --- a/public/music/js/common_ui.js +++ b/public/music/js/common_ui.js @@ -80,8 +80,8 @@ * 跳转至管理后台 */ function goToAdmin() { - var adminPath = (window.CONFIG && window.CONFIG['admin.path']) || ''; - location.href = adminPath || '/'; + var adminPath = (window.CONFIG && window.CONFIG['admin.path']) || '/music'; + location.href = adminPath; } /** diff --git a/public/music/js/download_manager.js b/public/music/js/download_manager.js index 1fac937..c453f29 100644 --- a/public/music/js/download_manager.js +++ b/public/music/js/download_manager.js @@ -1461,6 +1461,13 @@ class DownloadManager { } // Render a single task row item to HTML + addTaskToPlaylist(taskId) { + const task = this.tasks.find(item => item.id === taskId); + if (task?.song && typeof window.openPlaylistAddModalForSongObject === 'function') { + window.openPlaylistAddModalForSongObject(task.song); + } + } + renderTaskHtml(task) { const coverSrc = this.getSongCover(task.song); const sourceName = { @@ -1603,7 +1610,10 @@ class DownloadManager { -
    +
    + ${actionBtnHTML}
    diff --git a/public/music/js/leaderboard_manager.js b/public/music/js/leaderboard_manager.js index f91bb6c..91160c6 100644 --- a/public/music/js/leaderboard_manager.js +++ b/public/music/js/leaderboard_manager.js @@ -251,17 +251,22 @@ window.LeaderboardManager = (function () { ${song.interval || '--:--'} -
    - - +
    `; @@ -272,8 +277,10 @@ window.LeaderboardManager = (function () { } function renderPagination() { + const firstBtn = document.getElementById('lb-btn-first'); const prevBtn = document.getElementById('lb-btn-prev'); const nextBtn = document.getElementById('lb-btn-next'); + const lastBtn = document.getElementById('lb-btn-last'); const info = document.getElementById('lb-page-info'); const displayList = window.ListSearch && window.ListSearch.state && window.ListSearch.state.active && window.ListSearch.state.id === 'leaderboard' @@ -282,14 +289,17 @@ window.LeaderboardManager = (function () { const itemsPerPage = typeof settings !== 'undefined' ? (settings.itemsPerPage === 'all' ? displayList.length : parseInt(settings.itemsPerPage)) : 20; const totalItems = displayList.length; - const totalPages = Math.ceil(totalItems / (itemsPerPage || 20)) || 1; + const loadedPages = Math.ceil(totalItems / (itemsPerPage || 20)) || 1; + const totalPages = Math.max(loadedPages, Math.ceil((state.total || totalItems) / (itemsPerPage || 20)) || 1); + if (firstBtn) firstBtn.disabled = state.localPage <= 1; if (prevBtn) prevBtn.disabled = state.localPage <= 1; if (nextBtn) { // 当本地页数超出,且已经无法再次从后端拿到新数据时,才禁用“下一页” const canLoadMore = state.songs.length >= state.limit * state.page; nextBtn.disabled = state.localPage >= totalPages && !canLoadMore; } + if (lastBtn) lastBtn.disabled = state.localPage >= totalPages; if (info) info.innerText = `第 ${state.localPage} 页 / 共 ${totalPages} 页`; } @@ -392,6 +402,29 @@ window.LeaderboardManager = (function () { } } + async function goToBoundary(boundary) { + if (state.loading || !state.songs.length) return; + if (boundary === 'first') { + state.localPage = 1; + } else { + const expectedTotal = Math.max(state.total || 0, state.songs.length); + const requiredBackendPages = Math.max(1, Math.ceil(expectedTotal / (state.limit || expectedTotal || 1))); + for (let page = state.page + 1; page <= requiredBackendPages; page++) { + await loadSongs(state.currentBangid, state.source, page); + } + const displayList = window.ListSearch && window.ListSearch.state && window.ListSearch.state.active && window.ListSearch.state.id === 'leaderboard' + ? window.ListSearch.getDisplayList(state.songs) + : state.songs; + const itemsPerPage = typeof settings !== 'undefined' + ? (settings.itemsPerPage === 'all' ? displayList.length : parseInt(settings.itemsPerPage)) + : 20; + state.localPage = Math.max(1, Math.ceil(displayList.length / (itemsPerPage || 20))); + } + renderSongs(state.songs); + renderPagination(); + document.getElementById('lb-songs-container')?.scrollTo({ top: 0, behavior: 'smooth' }); + } + function changeSource() { const sel = document.getElementById('lb-source-select'); if (!sel) return; @@ -420,6 +453,15 @@ window.LeaderboardManager = (function () { } } + function addSongToPlaylist(index) { + const song = state.songs[index]; + if (!song || typeof window.openPlaylistAddModalForSongObject !== 'function') return; + window.openPlaylistAddModalForSongObject({ + ...song, + source: song.source || state.source + }); + } + // ==================== 公共方法 ==================== @@ -433,6 +475,8 @@ window.LeaderboardManager = (function () { playAll, changePage, handleRowClick, + addSongToPlaylist, + goToBoundary, renderSongs: function () { renderSongs(state.songs); @@ -535,4 +579,3 @@ function toggleLbSidebar(force) { }, 300); } } - diff --git a/public/music/js/local_music.js b/public/music/js/local_music.js index 874b939..febd217 100644 --- a/public/music/js/local_music.js +++ b/public/music/js/local_music.js @@ -556,6 +556,9 @@ window.LocalMusicManager = { case 'download': this.downloadSingle(index); break; + case 'playlist': + this.addItemToPlaylist(index); + break; case 'delete': this.deleteSingle(index); break; @@ -1174,8 +1177,12 @@ window.LocalMusicManager = { }, changePage(delta) { + this.goToPage(this.currentPage + delta); + }, + + goToPage(page) { const totalPages = this.getTotalPages(); - const nextPage = Math.min(totalPages, Math.max(1, this.currentPage + delta)); + const nextPage = page === 'last' ? totalPages : Math.min(totalPages, Math.max(1, Number(page) || 1)); if (nextPage === this.currentPage) return; this.currentPage = nextPage; this.render(); @@ -1186,8 +1193,10 @@ window.LocalMusicManager = { updatePagination() { const pagination = document.getElementById('lm-pagination'); const info = document.getElementById('lm-page-info'); + const first = document.getElementById('lm-page-first'); const prev = document.getElementById('lm-page-prev'); const next = document.getElementById('lm-page-next'); + const last = document.getElementById('lm-page-last'); if (!pagination) return; const total = this.displayData.length; @@ -1201,8 +1210,10 @@ window.LocalMusicManager = { } if (info) info.textContent = `第 ${this.currentPage} / ${totalPages} 页 (${total} 首)`; + if (first) first.disabled = this.currentPage <= 1; if (prev) prev.disabled = this.currentPage <= 1; if (next) next.disabled = this.currentPage >= totalPages; + if (last) last.disabled = this.currentPage >= totalPages; }, render() { @@ -1322,7 +1333,7 @@ window.LocalMusicManager = { -
    +
    ${coverHtml}
    @@ -1396,7 +1407,7 @@ window.LocalMusicManager = {
    -
    +
    @@ -1407,17 +1418,21 @@ window.LocalMusicManager = { ` : ''} +
    @@ -1659,6 +1674,21 @@ window.LocalMusicManager = { window.openPlaylistAddModal(collectableTargets.map(item => this.buildPlaylistSong(item)).filter(Boolean)); }, + addItemToPlaylist(index) { + const item = this.displayData[index]; + if (!item) return; + if (!this.isPlaylistCollectable(item)) { + if (typeof showError === 'function') { + showError('歌曲不在曲库中,无法收藏到歌单。请先使用“手动关联”绑定平台歌曲 ID。'); + } + return; + } + const song = this.buildPlaylistSong(item); + if (song && typeof window.openPlaylistAddModalForSongObject === 'function') { + window.openPlaylistAddModalForSongObject(song); + } + }, + playItem(index) { const item = this.displayData[index]; if (!item) return; @@ -2362,10 +2392,16 @@ window.LocalMusicManager = {
    ${item.source}
    ${item.interval || '--:--'}
    - +
    + + +
    `; }); @@ -2373,6 +2409,13 @@ window.LocalMusicManager = { container.innerHTML = html || `
    未找到搜索结果
    `; }, + addManualResultToPlaylist(index) { + const song = this.currentManualResults?.[index]; + if (song && typeof window.openPlaylistAddModalForSongObject === 'function') { + window.openPlaylistAddModalForSongObject(song); + } + }, + async linkItem(idx) { if (!this.manualIndexTargetItem || !this.currentManualResults || !this.currentManualResults[idx]) return; diff --git a/public/music/js/single_song_ops.js b/public/music/js/single_song_ops.js index 8442409..32a8170 100644 --- a/public/music/js/single_song_ops.js +++ b/public/music/js/single_song_ops.js @@ -157,6 +157,29 @@ function getSelectableQualityOrder(song = null) { (window.QualityManager?.QUALITY_PRIORITY ? [...window.QualityManager.QUALITY_PRIORITY].reverse() : ['128k', '320k', 'flac', 'flac24bit', 'hires', 'atmos', 'atmos_plus', 'master']); } +async function requestListSongRemoval(listId, songIds) { + const send = () => fetch('/api/music/user/list/remove', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getUserAuthHeaders() + }, + body: JSON.stringify({ listId, songIds }) + }); + + let response = await send(); + if (response.status === 401 && typeof ensureUserAuthToken === 'function') { + const refreshed = await ensureUserAuthToken({ force: true }); + if (refreshed) response = await send(); + } + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || '删除失败'); + } + return response; +} +window.requestListSongRemoval = requestListSongRemoval; + // Single song deletion async function deleteSingleSong(songId) { if (!(await showSelect('删除歌曲', '确定要删除这首歌曲吗?', { danger: true }))) { @@ -175,32 +198,15 @@ async function deleteSingleSong(songId) { } if (window.SyncManager.mode === 'local') { - // Local mode: Use user credentials - const username = localStorage.getItem('lx_sync_user'); - const password = localStorage.getItem('lx_sync_pass'); - - if (!username || !password) { + // Token authentication is sufficient; a saved plaintext password is not required. + const authHeaders = getUserAuthHeaders(); + if (!authHeaders['x-user-token'] && !authHeaders['x-user-password']) { showError('请先登录本地账号'); return; } try { - const res = await fetch('/api/music/user/list/remove', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...getUserAuthHeaders() - }, - body: JSON.stringify({ - listId: activeListId, - songIds: [songId] - }) - }); - - if (!res.ok) { - const errorText = await res.text(); - throw new Error(errorText || '删除失败'); - } + await requestListSongRemoval(activeListId, [songId]); // Reload data from server const data = await window.SyncManager.sync(); @@ -211,7 +217,7 @@ async function deleteSingleSong(songId) { renderMyLists(data); // Refresh current view - handleListClick(activeListId); + handleListClick(activeListId, true, true); console.log('[Single] 本地模式删除成功'); @@ -247,7 +253,7 @@ async function deleteSingleSong(songId) { // Update UI renderMyLists(currentListData); - handleListClick(activeListId); + handleListClick(activeListId, true, true); } catch (e) { showError('删除失败: ' + e.message); diff --git a/public/music/js/songlist_manager.js b/public/music/js/songlist_manager.js index 7d0fea4..ba42e50 100644 --- a/public/music/js/songlist_manager.js +++ b/public/music/js/songlist_manager.js @@ -513,17 +513,22 @@ window.SongListManager = (function () { ${song.interval || '--:--'}
    -
    - - +
    `}).join(''); @@ -538,10 +543,12 @@ window.SongListManager = (function () { } function updatePaginationUI() { - document.getElementById('songlist-page-info').innerText = `第 ${currentState.page} 页`; + const totalPages = Math.max(1, Math.ceil((currentState.total || currentState.list.length) / currentState.limit)); + document.getElementById('songlist-page-info').innerText = `第 ${currentState.page} / ${totalPages} 页`; + document.getElementById('btn-songlist-first').disabled = currentState.page <= 1; document.getElementById('btn-songlist-prev').disabled = currentState.page <= 1; - // Simplified check for next page, can be improved with total/limit - document.getElementById('btn-songlist-next').disabled = currentState.list.length < currentState.limit; + document.getElementById('btn-songlist-next').disabled = currentState.page >= totalPages; + document.getElementById('btn-songlist-last').disabled = currentState.page >= totalPages; } // --- Public Methods --- @@ -577,9 +584,13 @@ window.SongListManager = (function () { loadList(1); }, changePage: function (delta) { - const next = currentState.page + delta; - if (next < 1) return; - loadList(next); + this.goToPage(currentState.page + delta); + }, + goToPage: function (page) { + const totalPages = Math.max(1, Math.ceil((currentState.total || currentState.list.length) / currentState.limit)); + const target = page === 'last' ? totalPages : Math.min(totalPages, Math.max(1, Number(page) || 1)); + if (target === currentState.page) return; + loadList(target); document.getElementById('songlist-grid').scrollTo({ top: 0, behavior: 'smooth' }); }, openDetail: function (id, source) { @@ -600,6 +611,14 @@ window.SongListManager = (function () { window.updatePlaylist(listWithSource, index, 'songlist', true); } }, + addSongToPlaylist: function (index) { + const song = detailState.list[index]; + if (!song || typeof window.openPlaylistAddModalForSongObject !== 'function') return; + window.openPlaylistAddModalForSongObject({ + ...song, + source: song.source || detailState.source + }); + }, playAll: function () { if (detailState.list.length === 0) return; if (typeof window.updatePlaylist === 'function') { diff --git a/src/defaultConfig.ts b/src/defaultConfig.ts index 8ba4457..b900546 100644 --- a/src/defaultConfig.ts +++ b/src/defaultConfig.ts @@ -52,8 +52,8 @@ const config: LX.Config = { 'proxy.all.address': '', // 访问路径配置 - 'admin.path': '/admin', // 后台管理路径,默认为 /admin - 'player.path': '', // 播放器路径,默认为根路径 / + 'admin.path': '/music', // 后台管理路径 + 'player.path': '/', // 播放器路径,默认为根路径 / 'subsonic.enable': true, // 是否启用 Subsonic 服务 'subsonic.path': '/rest', // Subsonic 访问路径 'subsonic.enableDebug': false, // 是否开启 Subsonic 调试日志模式 diff --git a/src/server/server.ts b/src/server/server.ts index a784670..22990a6 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -12,7 +12,7 @@ import { SYNC_CODE, SYNC_CLOSE_CODE, } from '@/constants' -import { getUserSpace, releaseUserSpace, getUserName, getServerId, getUserDirname, migrateUserData, renameUserSpace, finishRenameUserSpace } from '@/user' +import { getUserSpace, releaseUserSpace, getUserName, getServerId, getUserDirname, getUserConfig, migrateUserData, renameUserSpace, finishRenameUserSpace } from '@/user' import { createMsg2call } from 'message2call' import { ElFinderConnector, getSystemRoot } from './elfinderConnector' import formidable from 'formidable' @@ -919,8 +919,8 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro // 读取路径配置(每次请求都重新读取,保存后立刻生效) const normalizePath = (p: string) => (p || '').replace(/\/+$/, '') - const playerPath = global.lx.config['player.path'] ?? '' - const adminPath = global.lx.config['admin.path'] ?? '/admin' + const playerPath = global.lx.config['player.path'] ?? '/' + const adminPath = global.lx.config['admin.path'] ?? '/music' // 映射播放器逻辑 (无论是自定义路径还是前端硬编码的 /music/) const isPlayerRequest = (playerPath === '/' || playerPath === '') @@ -1047,8 +1047,8 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro 'player.enableAuth': global.lx.config['player.enableAuth'] || false, port: global.lx.config.port, bindIP: global.lx.config.bindIP, - 'admin.path': global.lx.config['admin.path'] ?? '/admin', - 'player.path': global.lx.config['player.path'] ?? '', + 'admin.path': global.lx.config['admin.path'] ?? '/music', + 'player.path': global.lx.config['player.path'] ?? '/', } const configJs = `window.CONFIG = ${JSON.stringify(frontendConfig, null, 2)};` @@ -1643,7 +1643,7 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro void readBody(req).then(async body => { try { - const { listId, musicInfos, location = 'bottom' } = JSON.parse(body) + const { listId, musicInfos, location } = JSON.parse(body) if (!listId || !Array.isArray(musicInfos)) { res.writeHead(400) @@ -1656,7 +1656,10 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro const userSpace = getUserSpace(username) // Add songs to the list - await userSpace.listManage.listDataManage.listMusicAdd(listId, musicInfos, location) + const addMusicLocationType = location === 'top' || location === 'bottom' + ? location + : getUserConfig(username)['list.addMusicLocationType'] + await userSpace.listManage.listDataManage.listMusicAdd(listId, musicInfos, addMusicLocationType) // Create new snapshot to persist changes const newSnapshotKey = await userSpace.listManage.createSnapshot() @@ -5227,8 +5230,8 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro 'sync.backupInterval': global.lx.config['sync.backupInterval'] || 24, 'proxy.all.enabled': global.lx.config['proxy.all.enabled'] || false, 'proxy.all.address': global.lx.config['proxy.all.address'] || '', - 'admin.path': global.lx.config['admin.path'] ?? '/admin', - 'player.path': global.lx.config['player.path'] ?? '', + 'admin.path': global.lx.config['admin.path'] ?? '/music', + 'player.path': global.lx.config['player.path'] ?? '/', 'subsonic.enable': global.lx.config['subsonic.enable'] ?? true, 'subsonic.path': global.lx.config['subsonic.path'] ?? '/rest', 'subsonic.enableDebug': global.lx.config['subsonic.enableDebug'] ?? false, @@ -5307,8 +5310,8 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro if (newConfig['proxy.all.address'] !== undefined) global.lx.config['proxy.all.address'] = newConfig['proxy.all.address'] if (newConfig['admin.path'] !== undefined || newConfig['player.path'] !== undefined) { - const adminPath = (newConfig['admin.path'] !== undefined ? newConfig['admin.path'] : (global.lx.config['admin.path'] ?? '/admin')) - const playerPath = (newConfig['player.path'] !== undefined ? newConfig['player.path'] : (global.lx.config['player.path'] ?? '')) + const adminPath = (newConfig['admin.path'] !== undefined ? newConfig['admin.path'] : (global.lx.config['admin.path'] ?? '/music')) + const playerPath = (newConfig['player.path'] !== undefined ? newConfig['player.path'] : (global.lx.config['player.path'] ?? '/')) const normalizedAdmin = adminPath.replace(/\/+$/, '') const normalizedPlayer = playerPath.replace(/\/+$/, '') @@ -5405,8 +5408,8 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro 'sync.backupInterval': global.lx.config['sync.backupInterval'], 'proxy.all.enabled': global.lx.config['proxy.all.enabled'], 'proxy.all.address': global.lx.config['proxy.all.address'], - 'admin.path': global.lx.config['admin.path'] ?? '/admin', - 'player.path': global.lx.config['player.path'] ?? '', + 'admin.path': global.lx.config['admin.path'] ?? '/music', + 'player.path': global.lx.config['player.path'] ?? '/', 'subsonic.enable': global.lx.config['subsonic.enable'], 'subsonic.path': global.lx.config['subsonic.path'], 'subsonic.enableDebug': global.lx.config['subsonic.enableDebug'], diff --git a/src/server/subsonic.ts b/src/server/subsonic.ts index 402b2c2..2a5a9df 100644 --- a/src/server/subsonic.ts +++ b/src/server/subsonic.ts @@ -1,7 +1,7 @@ import http from 'http' import crypto from 'crypto' import { URL } from 'url' -import { getUserSpace, getUserDirname } from '@/user' +import { getUserSpace, getUserDirname, getUserConfig } from '@/user' import { callUserApiGetMusicUrl } from '@/server/userApi' import { getSingerPic, getSingerDetail, getSingerMid } from '@/server/utils/singer' import { fetchRecommendedAlbums } from '@/server/utils/recommendAlbums' @@ -31,6 +31,9 @@ class SubsonicHandler { // 在线全网搜索歌曲缓存 (ID -> MusicInfo),确保后续 getSong / getCoverArt / getLyrics 能精准查到歌曲元数据 private onlineSongCache = new Map() + // 固定同一关键词的在线结果顺序,避免客户端翻页时出现重复或跳项。 + private onlineSearchCache = new Map() + private cacheOnlineSong(music: LX.Music.MusicInfo) { if (!music || !music.id) return if (this.onlineSongCache.size > 5000) { @@ -202,9 +205,10 @@ class SubsonicHandler { }) // 合并 URL 参数和 Body 参数 const mergedParams = new URLSearchParams(params.toString()) - bodyParams.forEach((v, k) => { - if (!mergedParams.has(k)) mergedParams.set(k, v) - }) + for (const key of new Set(bodyParams.keys())) { + if (mergedParams.has(key)) continue + for (const value of bodyParams.getAll(key)) mergedParams.append(key, value) + } params = mergedParams } catch (e) { console.error('[Subsonic] POST body parse error:', e) @@ -328,6 +332,12 @@ class SubsonicHandler { case 'updatePlaylist': return this.handleUpdatePlaylist(res, username, params, format) + case 'createPlaylist': + return this.handleCreatePlaylist(res, username, params, format) + + case 'deletePlaylist': + return this.handleDeletePlaylist(res, username, params, format) + case 'scrobble': return this.sendResponse(res, {}, format) @@ -550,6 +560,23 @@ class SubsonicHandler { return null } + private getListParams(params: URLSearchParams, name: string): string[] { + return params.getAll(name) + .flatMap(value => value.split(',')) + .map(value => value.trim()) + .filter(Boolean) + } + + private async resolveMusicIds(username: string, ids: string[]): Promise { + const musics: LX.Music.MusicInfo[] = [] + for (const id of ids) { + const result = await this.findMusicById(username, id) + if (!result) return null + musics.push(result.music) + } + return musics + } + // ───────────────────────────────────────────── // 端点实现 // ───────────────────────────────────────────── @@ -566,13 +593,23 @@ class SubsonicHandler { } private handleGetMusicFolders(res: http.ServerResponse, format: string) { + const folders = [ + { id: '1', name: 'LX Music(按服务器设置)' }, + { id: 'local', name: '本地曲库' }, + { id: 'all', name: '全部在线平台' }, + { id: 'wy', name: '网易云音乐' }, + { id: 'tx', name: 'QQ 音乐' }, + { id: 'kw', name: '酷我音乐' }, + { id: 'kg', name: '酷狗音乐' }, + { id: 'mg', name: '咪咕音乐' }, + ] if (format === 'json') { return this.sendResponse(res, { - musicFolders: { musicFolder: [{ id: 1, name: 'LX Music' }] }, + musicFolders: { musicFolder: folders }, }, format) } return this.sendResponse(res, { - musicFolders: { children: { musicFolder: [{ attrs: { id: 1, name: 'LX Music' } }] } }, + musicFolders: { children: { musicFolder: folders.map(folder => ({ attrs: folder })) } }, }, format) } @@ -689,40 +726,128 @@ class SubsonicHandler { private async handleUpdatePlaylist(res: http.ServerResponse, username: string, params: URLSearchParams, format: string) { const playlistId = params.get('playlistId') - const songIndexToRemove = params.get('songIndexToRemove') - if (!playlistId) return this.sendError(res, 10, 'Required parameter is missing: playlistId', format) - // 目前 lxserver 下暂时只实现了通过索引删除 (OpenSubsonic 核心规范) - if (songIndexToRemove !== null) { - const index = parseInt(songIndexToRemove) - if (isNaN(index)) return this.sendError(res, 0, 'Invalid songIndexToRemove', format) - - try { - const userSpace = getUserSpace(username) - const musics = await userSpace.listManage.listDataManage.getListMusics(playlistId) - - if (index < 0 || index >= musics.length) { - return this.sendError(res, 0, 'Index out of bounds', format) + try { + const userSpace = getUserSpace(username) + const listData = await userSpace.listManage.getListData() + const userList = listData.userList.find(list => list.id === playlistId) + if (playlistId !== 'default' && playlistId !== 'love' && !userList) { + return this.sendError(res, 70, 'Playlist not found', format) + } + + const currentMusics = await userSpace.listManage.listDataManage.getListMusics(playlistId) + const removeIndexes = this.getListParams(params, 'songIndexToRemove').map(Number) + if (removeIndexes.some(index => !Number.isInteger(index) || index < 0 || index >= currentMusics.length)) { + return this.sendError(res, 0, 'Invalid songIndexToRemove', format) + } + // The original Subsonic API removes by zero-based index. A number of + // clients use the OpenSubsonic-style songIdToRemove extension instead. + const requestedRemoveIds = this.getListParams(params, 'songIdToRemove') + + const addIds = [...new Set(this.getListParams(params, 'songIdToAdd'))] + const addMusics = await this.resolveMusicIds(username, addIds) + if (addMusics === null) return this.sendError(res, 70, 'Song not found', format) + + const addIndexValues = this.getListParams(params, 'songIndexToAdd') + const addIndex = addIndexValues.length ? Number(addIndexValues[0]) : null + if (addIndex !== null && (!Number.isInteger(addIndex) || addIndex < 0)) { + return this.sendError(res, 0, 'Invalid songIndexToAdd', format) + } + + let changed = false + const name = params.get('name') + if (name !== null) { + if (!userList) return this.sendError(res, 0, 'Built-in playlists cannot be renamed', format) + if (!name.trim()) return this.sendError(res, 0, 'Playlist name cannot be empty', format) + await userSpace.listManage.listDataManage.userListsUpdate([{ + ...userList, + name: name.trim(), + locationUpdateTime: Date.now(), + }]) + changed = true + } + + const removeIds = [...new Set([ + ...removeIndexes.map(index => currentMusics[index].id), + ...requestedRemoveIds, + ])] + if (removeIds.length) { + await userSpace.listManage.listDataManage.listMusicRemove(playlistId, removeIds) + changed = true + } + + if (addMusics.length) { + const location = getUserConfig(username)['list.addMusicLocationType'] + await userSpace.listManage.listDataManage.listMusicAdd(playlistId, addMusics, location) + if (addIndex !== null) { + await userSpace.listManage.listDataManage.listMusicUpdatePosition( + playlistId, + addIndex, + addMusics.map(music => music.id), + ) } + changed = true + } - const songId = musics[index].id - // console.log(`[Subsonic] Removing song at index ${index} (ID: ${songId}) from playlist ${playlistId}`) + if (changed) await userSpace.listManage.createSnapshot() + return this.sendResponse(res, {}, format) + } catch (err: any) { + console.error('[Subsonic] updatePlaylist error:', err) + return this.sendError(res, 0, err.message || 'Failed to update playlist', format) + } + } - // 执行物理删除 - await userSpace.listManage.listDataManage.listMusicRemove(playlistId, [songId]) - // 创建快照持久化 - await userSpace.listManage.createSnapshot() + private async handleCreatePlaylist(res: http.ServerResponse, username: string, params: URLSearchParams, format: string) { + const name = params.get('name')?.trim() + if (!name) return this.sendError(res, 10, 'Required parameter is missing: name', format) - return this.sendResponse(res, {}, format) - } catch (err: any) { - console.error('[Subsonic] updatePlaylist error:', err) - return this.sendError(res, 0, err.message || 'Failed to remove song', format) + try { + const songIds = [...new Set(this.getListParams(params, 'songId'))] + const musics = await this.resolveMusicIds(username, songIds) + if (musics === null) return this.sendError(res, 70, 'Song not found', format) + + const userSpace = getUserSpace(username) + const playlistId = `subsonic_${crypto.randomUUID()}` + await userSpace.listManage.listDataManage.userListCreate({ + id: playlistId, + name, + position: -1, + locationUpdateTime: Date.now(), + }) + if (musics.length) { + const location = getUserConfig(username)['list.addMusicLocationType'] + await userSpace.listManage.listDataManage.listMusicAdd(playlistId, musics, location) } + await userSpace.listManage.createSnapshot() + + return this.handleGetPlaylist(res, username, new URLSearchParams({ id: playlistId }), format) + } catch (err: any) { + console.error('[Subsonic] createPlaylist error:', err) + return this.sendError(res, 0, err.message || 'Failed to create playlist', format) } + } - // TODO: 支持 songIdToAdd 等其他参数 - return this.sendResponse(res, {}, format) + private async handleDeletePlaylist(res: http.ServerResponse, username: string, params: URLSearchParams, format: string) { + const id = params.get('id') + if (!id) return this.sendError(res, 10, 'Required parameter is missing: id', format) + if (id === 'default' || id === 'love') { + return this.sendError(res, 0, 'Built-in playlists cannot be deleted', format) + } + + try { + const userSpace = getUserSpace(username) + const listData = await userSpace.listManage.getListData() + if (!listData.userList.some(list => list.id === id)) { + return this.sendError(res, 70, 'Playlist not found', format) + } + await userSpace.listManage.listDataManage.userListsRemove([id]) + await userSpace.listManage.createSnapshot() + return this.sendResponse(res, {}, format) + } catch (err: any) { + console.error('[Subsonic] deletePlaylist error:', err) + return this.sendError(res, 0, err.message || 'Failed to delete playlist', format) + } } // getAlbum: 返回 album + song[] 格式(音流等客户端期望的格式) @@ -1437,12 +1562,18 @@ class SubsonicHandler { private async fetchOnlineSearchSongs(cleanQuery: string, sources: string[], limit: number = 30): Promise<{ music: LX.Music.MusicInfo, listId: string }[]> { if (!cleanQuery) return [] - const results: { music: LX.Music.MusicInfo, listId: string }[] = [] const validSources = sources.filter(s => ['wy', 'tx', 'kw', 'kg', 'mg'].includes(s) && musicSdk[s]?.musicSearch?.search) - // [限制] 单个平台最大获取数量上限 - const targetLimit = Math.min(limit, 50) + const cacheKey = `${cleanQuery.toLowerCase()}::${validSources.join(',')}` + const cached = this.onlineSearchCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) return cached.results + + const sourceResults = new Map() + // 每个平台一次取足上限,后续 songOffset 分页复用同一批稳定结果。 + const targetLimit = 50 await Promise.all(validSources.map(async source => { + const results: { music: LX.Music.MusicInfo, listId: string }[] = [] + sourceResults.set(source, results) try { // 计算需要的页数 (网易云 wy 单页限制 20 条,如需要 50 条则自动抓取前 3 页) const pageSize = source === 'kg' ? Math.min(targetLimit, 100) : source === 'wy' ? 20 : 30 @@ -1499,7 +1630,21 @@ class SubsonicHandler { console.error(`[Subsonic] Online search error for source=${source}:`, err?.message || err) } })) - return results + + const interleaved: { music: LX.Music.MusicInfo, listId: string }[] = [] + const maxLength = Math.max(0, ...validSources.map(source => sourceResults.get(source)?.length || 0)) + for (let index = 0; index < maxLength; index++) { + for (const source of validSources) { + const item = sourceResults.get(source)?.[index] + if (item) interleaved.push(item) + } + } + if (this.onlineSearchCache.size >= 100) { + const firstKey = this.onlineSearchCache.keys().next().value + if (firstKey) this.onlineSearchCache.delete(firstKey) + } + this.onlineSearchCache.set(cacheKey, { expiresAt: Date.now() + 5 * 60 * 1000, results: interleaved }) + return interleaved } private async handleSearch(res: http.ServerResponse, username: string, params: URLSearchParams, format: string, method: string = 'search3') { @@ -1534,13 +1679,27 @@ class SubsonicHandler { targetOnlineSources = [matchedPrefixSource] const colonIdx = rawQuery.indexOf(':') !== -1 ? rawQuery.indexOf(':') : rawQuery.indexOf(':') cleanQuery = rawQuery.slice(colonIdx + 1).trim() + } else if (lowerQuery.startsWith('all:') || lowerQuery.startsWith('all:')) { + searchMode = 'force_online' + const colonIdx = rawQuery.indexOf(':') !== -1 ? rawQuery.indexOf(':') : rawQuery.indexOf(':') + cleanQuery = rawQuery.slice(colonIdx + 1).trim() } else { - // 没有前缀,遵循全局后台配置 - const isOnlineEnabled = global.lx.config['subsonic.onlineSearch'] !== false - if (!isOnlineEnabled) { + const requestedSource = (params.get('source') || params.get('musicFolderId') || '').trim().toLowerCase() + if (requestedSource === 'local') { searchMode = 'local_only' + } else if (requestedSource === 'all') { + searchMode = 'force_online' + } else if (knownSources.includes(requestedSource)) { + searchMode = 'force_online' + targetOnlineSources = [requestedSource] } else { - searchMode = (global.lx.config['subsonic.onlineSearchMode'] as any) || 'fallback' + // 没有明确指定音源时,遵循全局后台配置 + const isOnlineEnabled = global.lx.config['subsonic.onlineSearch'] !== false + if (!isOnlineEnabled) { + searchMode = 'local_only' + } else { + searchMode = (global.lx.config['subsonic.onlineSearchMode'] as any) || 'fallback' + } } } } @@ -1700,14 +1859,15 @@ class SubsonicHandler { const albumOffset = parseInt(params.get('albumOffset') || '0') const songCount = params.has('songCount') ? parseInt(params.get('songCount') || '20') : 20 const songOffset = parseInt(params.get('songOffset') || '0') + const requestedSongEnd = Math.max(0, songOffset) + Math.max(0, songCount) // 6. 处理在线 API 搜索与模式融合 if (cleanQuery && songCount > 0) { if (searchMode === 'force_online') { - const onlineResults = await this.fetchOnlineSearchSongs(cleanQuery, targetOnlineSources, songCount) + const onlineResults = await this.fetchOnlineSearchSongs(cleanQuery, targetOnlineSources, requestedSongEnd) matchedSongs = onlineResults } else if (searchMode === 'merge') { - const onlineResults = await this.fetchOnlineSearchSongs(cleanQuery, targetOnlineSources, songCount) + const onlineResults = await this.fetchOnlineSearchSongs(cleanQuery, targetOnlineSources, requestedSongEnd) const existingIds = new Set(matchedSongs.map(s => s.music.id)) for (const item of onlineResults) { if (!existingIds.has(item.music.id)) { @@ -1716,8 +1876,8 @@ class SubsonicHandler { } } } else if (searchMode === 'fallback') { - if (matchedSongs.length < songCount) { - const needed = songCount - matchedSongs.length + if (matchedSongs.length < requestedSongEnd) { + const needed = requestedSongEnd - matchedSongs.length const onlineResults = await this.fetchOnlineSearchSongs(cleanQuery, targetOnlineSources, needed) const existingIds = new Set(matchedSongs.map(s => s.music.id)) for (const item of onlineResults) { diff --git a/test/regressions.test.cjs b/test/regressions.test.cjs new file mode 100644 index 0000000..50367cd --- /dev/null +++ b/test/regressions.test.cjs @@ -0,0 +1,552 @@ +const assert = require('node:assert/strict') +const crypto = require('node:crypto') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { spawn } = require('node:child_process') +const test = require('node:test') +const vm = require('node:vm') + +const repositoryRoot = path.resolve(__dirname, '..') + +const extractFunction = (source, declaration) => { + const start = source.indexOf(declaration) + assert.notEqual(start, -1, `Missing function: ${declaration}`) + + const bodyStart = source.indexOf('{', start) + let depth = 0 + for (let index = bodyStart; index < source.length; index++) { + if (source[index] === '{') depth++ + if (source[index] !== '}') continue + depth-- + if (depth === 0) return source.slice(start, index + 1) + } + throw new Error(`Unterminated function: ${declaration}`) +} + +test('web player honors bottom when adding a song to the default list', async () => { + const appSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/app.js'), 'utf8') + const configFunctionSource = extractFunction(appSource, 'function getConfiguredAddMusicLocationType()') + const addFunctionSource = extractFunction(appSource, 'function addMusicByConfiguredLocation(list, music)') + const functionSource = extractFunction(appSource, 'async function addToDefaultList(song)') + const currentListData = { + defaultList: [{ id: 'existing', name: 'Existing' }], + loveList: [], + userList: [], + } + const context = vm.createContext({ + currentListData, + window: { CONFIG: { 'list.addMusicLocationType': 'bottom' } }, + cleanSongData: song => song, + pushDataChange: async () => {}, + renderMyLists: () => {}, + console, + }) + vm.runInContext(configFunctionSource, context) + vm.runInContext(addFunctionSource, context) + const addToDefaultList = vm.runInContext(`(${functionSource})`, context) + + await addToDefaultList({ id: 'new', name: 'New' }) + + assert.deepEqual(currentListData.defaultList.map(song => song.id), ['existing', 'new']) +}) + +test('web player starts recovery when a resolved online URL fails to play', async () => { + const appSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/app.js'), 'utf8') + const functionSource = extractFunction(appSource, 'async function playSong(song, index, forceQuality = null, noPlay = false, isRetry = false, shouldAddToDefault = null)') + const listeners = new Map() + const audio = { + paused: true, + ended: false, + src: '', + volume: 1, + playError: null, + addEventListener(name, listener) { + const handlers = listeners.get(name) || [] + handlers.push(listener) + listeners.set(name, handlers) + }, + removeEventListener(name, listener) { + listeners.set(name, (listeners.get(name) || []).filter(handler => handler !== listener)) + }, + pause() { this.paused = true }, + async play() { + if (this.playError) throw this.playError + this.paused = false + }, + dispatch(name) { + for (const listener of [...(listeners.get(name) || [])]) listener({ type: name }) + }, + } + let recoveryCalls = 0 + const context = vm.createContext({ + audio, + settings: { + playbackErrorPriority: 'platform', + enableAutoDegradeQuality: false, + enableAutoSwitchSource: true, + enableAutoSkipOnError: false, + preferredQuality: '128k', + enableCrossfade: false, + enableServerLyricCache: false, + }, + window: { + QualityManager: { getBestQuality: () => '128k' }, + currentPlayingSong: null, + _autoSkipTimer: null, + }, + document: { getElementById: () => null }, + prefetchManager: { get: () => null, bufferer: { src: '' } }, + resolveSongUrl: async () => ({ url: 'https://invalid.example/song.mp3', sourceType: 'normal', quality: '128k' }), + runRecoveryFlow: async () => { recoveryCalls++ }, + updatePlayerInfo: () => {}, + updateMediaSessionMetadata: () => {}, + fetchLyric: () => {}, + renderQueue: () => {}, + showInfo: () => {}, + showSuccess: () => {}, + showError: () => {}, + setPlayerStatus: () => {}, + updatePlayButton: () => {}, + getSourceTypeText: () => '在线解析', + cleanSongData: song => song, + savePlayHistory: () => {}, + addToDefaultList: () => {}, + prefetchNextSong: () => {}, + localStorage: { removeItem: () => {} }, + console, + currentLoadingSongId: null, + loadingRequestCounter: 0, + currentLoadingRequestId: 0, + currentRecoveryState: null, + currentPlaybackErrorHandler: null, + shouldAutoRecoverPlayback: false, + playbackRecoveryTriggeredForRequestId: 0, + currentIndex: -1, + preSelectedNextIndex: null, + currentPlayingSong: null, + currentQuality: null, + currentSourceType: 'normal', + currentPlayingScope: 'local_list', + currentRawLrc: '', + currentRawTlrc: '', + currentRawRlrc: '', + currentRawKlrc: '', + currentVolume: 1, + isUserScrolling: false, + scrollLockTimeout: null, + hintTimeout: null, + }) + vm.runInContext(functionSource, context) + + await context.playSong({ id: 'song-a', name: 'Song A', singer: 'Artist', source: 'wy' }, 0) + audio.dispatch('error') + await new Promise(resolve => setImmediate(resolve)) + + assert.equal(recoveryCalls, 1) + + audio.playError = Object.assign(new Error('Unsupported audio source'), { name: 'NotSupportedError' }) + await context.playSong({ id: 'song-b', name: 'Song B', singer: 'Artist', source: 'wy' }, 1) + + assert.equal(recoveryCalls, 2) +}) + +test('web player recovers when playback pauses unexpectedly', async () => { + const appSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/app.js'), 'utf8') + const functionSource = extractFunction(appSource, 'function handleUnexpectedPlaybackPause()') + let recoveryCalls = 0 + const context = vm.createContext({ + audio: { paused: true, ended: false, src: 'https://invalid.example/song.mp3' }, + shouldAutoRecoverPlayback: true, + currentRecoveryState: { thisRequestId: 7 }, + currentPlaybackErrorHandler: null, + playbackRecoveryTriggeredForRequestId: 0, + runRecoveryFlow: async () => { recoveryCalls++ }, + Error, + }) + vm.runInContext(functionSource, context) + + const recovered = context.handleUnexpectedPlaybackPause() + await new Promise(resolve => setImmediate(resolve)) + + assert.equal(recovered, true) + assert.equal(recoveryCalls, 1) + assert.equal(context.shouldAutoRecoverPlayback, false) +}) + +test('web player continues source recovery after an earlier source switch', async () => { + const appSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/app.js'), 'utf8') + const functionSource = extractFunction(appSource, 'async function runRecoveryFlow(error)') + const originalSong = { id: 'wy-a', source: 'wy', name: 'Song A' } + const alreadyTried = { id: 'tx-a', source: 'tx', name: 'Song A' } + const nextSource = { id: 'kw-a', source: 'kw', name: 'Song A' } + const playedSources = [] + const context = vm.createContext({ + currentRecoveryState: { + originalSong, + currentSong: alreadyTried, + currentIndex: 0, + currentQuality: '128k', + triedQualities: ['128k'], + triedPlatforms: ['wy', 'tx'], + steps: ['switch_platform'], + currentStepIndex: 0, + }, + findOtherSourceMatches: async () => [alreadyTried, nextSource], + playSong: song => { playedSources.push(song.source) }, + settings: { preferredQuality: '128k' }, + window: { QualityManager: { getBestQuality: () => '128k' }, _autoSkipTimer: null }, + showInfo: () => {}, + showError: () => {}, + setPlayerStatus: () => {}, + updatePlayButton: () => {}, + getSourceName: source => source, + console, + }) + vm.runInContext(functionSource, context) + + await context.runRecoveryFlow(new Error('failed')) + + assert.deepEqual(playedSources, ['kw']) +}) + +test('song rows expose an add-to-playlist action', () => { + const appSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/app.js'), 'utf8') + assert.match(appSource, /title="添加到歌单"/) + assert.match(appSource, /openPlaylistAddModalForSong\(actualIndexInOriginal\)/) +}) + +test('every song list uses the checked single-song playlist picker', () => { + const appSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/app.js'), 'utf8') + const songListSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/js/songlist_manager.js'), 'utf8') + const leaderboardSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/js/leaderboard_manager.js'), 'utf8') + const localMusicSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/js/local_music.js'), 'utf8') + const downloadSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/js/download_manager.js'), 'utf8') + + assert.match(appSource, /window\.playlistAddTargetSong/) + assert.match(appSource, /window\.openPlaylistAddModalForSongObject/) + assert.match(appSource, /openPlaylistAddModalForSongObject\(currentPlaylist\[\$\{index\}\]\)/) + assert.match(appSource, /openPlaylistAddModalForSong\(index\)/) + assert.match(appSource, /bg-red-500 text-white[\s\S]*fa-check/) + assert.match(songListSource, /SongListManager\.addSongToPlaylist\(\$\{index\}\)/) + assert.match(leaderboardSource, /LeaderboardManager\.addSongToPlaylist\(\$\{index\}\)/) + assert.match(localMusicSource, /data-lm-action="playlist"/) + assert.match(localMusicSource, /addItemToPlaylist\(index\)/) + assert.match(downloadSource, /addTaskToPlaylist\('\$\{task\.id\}'\)/) + assert.match(appSource, /openCacheItemPlaylist\(\$\{idx\}\)/) +}) + +test('single-song playlist picker marks existing playlists with a red check', () => { + const appSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/app.js'), 'utf8') + const functionSource = extractFunction(appSource, 'function renderPlaylistAddGrid()') + const listContainer = { + children: [], + innerHTML: '', + appendChild(child) { this.children.push(child) }, + } + const context = vm.createContext({ + window: { + batchCollectSongs: null, + playlistAddTargetSong: { id: 'song-a', name: 'Song A' }, + myPersonalListData: null, + }, + currentPlayingSong: null, + currentListData: { + loveList: [{ id: 'song-a' }], + userList: [{ id: 'other', name: 'Other', list: [] }], + }, + document: { + getElementById: id => id === 'playlist-add-list' ? listContainer : null, + createElement: () => ({ className: '', innerHTML: '', onclick: null }), + }, + cleanSongData: song => song, + isUserLoggedIn: () => false, + handleTogglePlaylist: () => {}, + handleCreateList: () => {}, + }) + vm.runInContext(functionSource, context) + context.renderPlaylistAddGrid() + + assert.match(listContainer.children[0].className, /bg-red-500/) + assert.match(listContainer.children[0].innerHTML, /fa-check/) + assert.match(listContainer.children[1].className, /bg-emerald-50/) + assert.doesNotMatch(listContainer.children[1].innerHTML, /fa-check/) +}) + +test('song pagination exposes first and last page controls', () => { + const html = fs.readFileSync(path.join(repositoryRoot, 'public/music/index.html'), 'utf8') + const appSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/app.js'), 'utf8') + const paginationSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/js/batch_pagination.js'), 'utf8') + const songListSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/js/songlist_manager.js'), 'utf8') + const leaderboardSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/js/leaderboard_manager.js'), 'utf8') + const localMusicSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/js/local_music.js'), 'utf8') + + for (const id of ['search-btn-first', 'search-btn-last', 'btn-songlist-first', 'btn-songlist-last', 'lb-btn-first', 'lb-btn-last', 'lm-page-first', 'lm-page-last']) { + assert.ok(html.includes(`id="${id}"`), `Missing pagination control ${id}`) + } + assert.match(paginationSource, /function firstPage\(\)/) + assert.match(paginationSource, /function lastPage\(\)/) + assert.match(appSource, /function artistSongsFirstPage\(\)/) + assert.match(appSource, /function artistSongsLastPage\(\)/) + assert.match(songListSource, /goToPage: function \(page\)/) + assert.match(leaderboardSource, /async function goToBoundary\(boundary\)/) + assert.match(localMusicSource, /goToPage\(page\)/) +}) + +test('unselected playlist choices use the default green style', () => { + const appSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/app.js'), 'utf8') + const functionSource = extractFunction(appSource, 'function renderPlaylistAddGrid()') + assert.match(functionSource, /bg-emerald-50 text-emerald-500/) + assert.doesNotMatch(functionSource, /else \{\s*className \+= "bg-red-50/) + + const buttonSources = [ + appSource, + fs.readFileSync(path.join(repositoryRoot, 'public/music/js/songlist_manager.js'), 'utf8'), + fs.readFileSync(path.join(repositoryRoot, 'public/music/js/leaderboard_manager.js'), 'utf8'), + fs.readFileSync(path.join(repositoryRoot, 'public/music/js/local_music.js'), 'utf8'), + fs.readFileSync(path.join(repositoryRoot, 'public/music/js/download_manager.js'), 'utf8'), + ] + const addButtons = buttonSources.flatMap(source => [...source.matchAll(/]*title="添加到歌单"[^>]*>/g)].map(match => match[0])) + assert.ok(addButtons.length >= 8) + for (const button of addButtons) assert.doesNotMatch(button, /(?:text|bg|border)-(?:red|rose)-/) +}) + +test('adding a song validates playlist data and preserves the current page', () => { + const appSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/app.js'), 'utf8') + assert.match(appSource, /async function ensurePlaylistDataAvailable\(\)/) + assert.match(appSource, /await ensurePlaylistDataAvailable\(\)/) + assert.match(appSource, /function handleListClick\(listId, skipAutoUpdate = false, preservePage = false\)/) + assert.match(appSource, /if \(!preservePage\) \{\s*currentPage = 1;/) + assert.match(appSource, /handleListClick\(window\.currentViewingListId, true, true\)/) +}) + +test('player sidebar links directly to the admin page', () => { + const html = fs.readFileSync(path.join(repositoryRoot, 'public/music/index.html'), 'utf8') + assert.match(html, /id="nav-admin-link"/) + assert.match(html, /onclick="event\.preventDefault\(\); goToAdmin\(\)"/) +}) + +test('admin page documents all Subsonic search scopes', () => { + const html = fs.readFileSync(path.join(repositoryRoot, 'public/index.html'), 'utf8') + for (const prefix of ['all:', 'online:', 'local:', 'wy:', 'tx:', 'kw:', 'kg:', 'mg:']) { + assert.ok(html.includes(prefix), `Missing Subsonic search prefix ${prefix}`) + } +}) + +test('token-authenticated local list deletion is not blocked by a missing saved password', async () => { + const opsSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/js/single_song_ops.js'), 'utf8') + const functionSource = extractFunction(opsSource, 'async function deleteSingleSong(songId)') + let fetchCalls = 0 + const context = vm.createContext({ + showSelect: async () => true, + requireAdminForOpenWrite: async () => true, + getCurrentActiveListId: () => 'default', + currentListData: { username: 'test', defaultList: [{ id: 'song-a' }], loveList: [], userList: [] }, + localStorage: { + getItem(key) { + if (key === 'lx_sync_user') return 'test' + if (key === 'lx_user_token') return 'token' + return null + }, + }, + getUserAuthHeaders: () => ({ 'x-user-name': 'test', 'x-user-token': 'token' }), + requestListSongRemoval: async () => { fetchCalls++ }, + window: { + SyncManager: { mode: 'local', sync: async () => ({ defaultList: [], loveList: [], userList: [] }) }, + ListStore: { set: async () => {} }, + }, + renderMyLists: () => {}, + handleListClick: () => {}, + showError: () => {}, + console, + }) + vm.runInContext(functionSource, context) + + await context.deleteSingleSong('song-a') + + assert.equal(fetchCalls, 1) +}) + +test('web player exports built-in and custom playlists as local JSON files', () => { + const appSource = fs.readFileSync(path.join(repositoryRoot, 'public/music/app.js'), 'utf8') + const functionSource = extractFunction(appSource, 'function buildPlaylistExport(listId, exportedAt = new Date().toISOString())') + const context = vm.createContext({ + currentListData: { + defaultList: [{ id: 'song-a', name: 'Song A' }], + loveList: [], + userList: [{ id: 'custom', name: '夜晚/放松', source: 'wy', sourceListId: '123', list: [{ id: 'song-b', name: 'Song B' }] }], + }, + }) + vm.runInContext(functionSource, context) + + const builtIn = context.buildPlaylistExport('default', '2026-08-09T00:00:00.000Z') + assert.equal(builtIn.fileName, '默认列表.json') + assert.deepEqual(JSON.parse(builtIn.json).playlist.list.map(song => song.id), ['song-a']) + + const custom = context.buildPlaylistExport('custom', '2026-08-09T00:00:00.000Z') + assert.equal(custom.fileName, '夜晚_放松.json') + const payload = JSON.parse(custom.json) + assert.equal(payload.type, 'lxserver-playlist') + assert.equal(payload.version, 1) + assert.equal(payload.playlist.sourceListId, '123') + assert.deepEqual(payload.playlist.list.map(song => song.id), ['song-b']) +}) + +const waitForServer = async (baseUrl, process, getOutput) => { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + if (process.exitCode !== null) { + throw new Error(`Server exited with ${process.exitCode}:\n${getOutput()}`) + } + try { + const response = await fetch(`${baseUrl}/rest/ping?u=test&p=test-pass&f=json`) + if (response.ok) return + } catch {} + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`Timed out waiting for test server:\n${getOutput()}`) +} + +test('Subsonic playlist mutation endpoints persist changes', async t => { + const dataPath = fs.mkdtempSync(path.join(os.tmpdir(), 'lxserver-subsonic-')) + const userDir = path.join(dataPath, 'users', `test_${crypto.createHash('md5').update('test').digest('hex').slice(0, 6)}`) + const listDir = path.join(userDir, 'list') + const snapshotDir = path.join(listDir, 'snapshot') + fs.mkdirSync(snapshotDir, { recursive: true }) + fs.writeFileSync(path.join(dataPath, 'users.json'), JSON.stringify([ + { name: 'test', password: 'test-pass' }, + ])) + + const initialData = { + defaultList: [{ id: 'song-a', name: 'Song A', singer: 'Artist A', source: 'wy', songmid: 'a' }], + loveList: [{ id: 'song-b', name: 'Song B', singer: 'Artist B', source: 'wy', songmid: 'b' }], + userList: [{ + id: 'source-list', + name: 'Source list', + list: [{ id: 'song-c', name: 'Song C', singer: 'Artist C', source: 'wy', songmid: 'c' }], + }], + } + const snapshotJson = JSON.stringify(initialData) + const snapshotId = crypto.createHash('md5').update(snapshotJson).digest('hex') + fs.writeFileSync(path.join(snapshotDir, `snapshot_${snapshotId}`), snapshotJson) + fs.writeFileSync(path.join(listDir, 'snapshotInfo.json'), JSON.stringify({ + latest: snapshotId, + time: Date.now(), + list: [], + clients: {}, + })) + + const port = 19_000 + (process.pid % 1_000) + const baseUrl = `http://127.0.0.1:${port}` + let output = '' + const server = spawn(process.execPath, ['--import', 'tsx', 'src/index.ts'], { + cwd: repositoryRoot, + env: { + ...process.env, + BIND_IP: '127.0.0.1', + CONFIG_PATH: path.join(dataPath, 'config.js'), + DATA_PATH: dataPath, + DISABLE_TELEMETRY: 'true', + LIST_ADD_MUSIC_LOCATION_TYPE: 'bottom', + PORT: String(port), + ADMIN_PATH: '/music', + PLAYER_PATH: '/', + SUBSONIC_ENABLE: 'true', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + server.stdout.on('data', chunk => { output = (output + chunk).slice(-8_000) }) + server.stderr.on('data', chunk => { output = (output + chunk).slice(-8_000) }) + t.after(() => { + if (server.exitCode === null) server.kill('SIGTERM') + fs.rmSync(dataPath, { recursive: true, force: true }) + }) + await waitForServer(baseUrl, server, () => output) + + const playerHtml = await (await fetch(`${baseUrl}/`)).text() + assert.match(playerHtml, /LX Music Web<\/title>/) + const adminHtml = await (await fetch(`${baseUrl}/music/`)).text() + assert.match(adminHtml, /<title>LX Music Sync Server - 管理控制台<\/title>/) + + const call = async (method, params = {}) => { + const query = new URLSearchParams({ u: 'test', p: 'test-pass', f: 'json', ...params }) + const response = await fetch(`${baseUrl}/rest/${method}?${query}`) + assert.equal(response.status, 200) + return response.json() + } + const post = async (method, entries) => { + const query = new URLSearchParams({ u: 'test', p: 'test-pass', f: 'json' }) + const body = new URLSearchParams(entries) + const response = await fetch(`${baseUrl}/rest/${method}?${query}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + assert.equal(response.status, 200) + return response.json() + } + const responseBody = body => body['subsonic-response'] + + const folders = responseBody(await call('getMusicFolders')).musicFolders.musicFolder + assert.deepEqual(folders.map(folder => folder.id), ['1', 'local', 'all', 'wy', 'tx', 'kw', 'kg', 'mg']) + + assert.equal(responseBody(await call('updatePlaylist', { + playlistId: 'default', + songIdToAdd: 'song-b', + })).status, 'ok') + let playlist = responseBody(await call('getPlaylist', { id: 'default' })).playlist + assert.deepEqual(playlist.entry.map(song => song.id), ['song-a', 'song-b']) + + assert.equal(responseBody(await call('updatePlaylist', { + playlistId: 'default', + songIdToAdd: 'song-c', + songIndexToAdd: '0', + })).status, 'ok') + playlist = responseBody(await call('getPlaylist', { id: 'default' })).playlist + assert.deepEqual(playlist.entry.map(song => song.id), ['song-c', 'song-a', 'song-b']) + + const created = responseBody(await call('createPlaylist', { + name: 'Created through Subsonic', + songId: 'song-b', + })) + assert.equal(created.status, 'ok') + const createdId = created.playlist?.id + assert.ok(createdId) + + assert.equal(responseBody(await post('updatePlaylist', [ + ['playlistId', createdId], + ['songIdToAdd', 'song-a'], + ['songIdToAdd', 'song-c'], + ])).status, 'ok') + playlist = responseBody(await call('getPlaylist', { id: createdId })).playlist + assert.deepEqual(playlist.entry.map(song => song.id), ['song-b', 'song-a', 'song-c']) + + assert.equal(responseBody(await post('updatePlaylist', [ + ['playlistId', createdId], + ['songIndexToRemove', '1'], + ])).status, 'ok') + playlist = responseBody(await call('getPlaylist', { id: createdId })).playlist + assert.deepEqual(playlist.entry.map(song => song.id), ['song-b', 'song-c']) + + // Some clients send a song ID even though the original Subsonic API specifies an index. + assert.equal(responseBody(await post('updatePlaylist', [ + ['playlistId', createdId], + ['songIdToRemove', 'song-b'], + ])).status, 'ok') + playlist = responseBody(await call('getPlaylist', { id: createdId })).playlist + assert.deepEqual(playlist.entry.map(song => song.id), ['song-c']) + + assert.equal(responseBody(await call('updatePlaylist', { + playlistId: createdId, + name: 'Renamed through Subsonic', + })).status, 'ok') + playlist = responseBody(await call('getPlaylist', { id: createdId })).playlist + assert.equal(playlist.name, 'Renamed through Subsonic') + + let playlists = responseBody(await call('getPlaylists')).playlists.playlist + assert.ok(playlists.some(item => item.id === createdId)) + + assert.equal(responseBody(await call('deletePlaylist', { id: createdId })).status, 'ok') + playlists = responseBody(await call('getPlaylists')).playlists.playlist + assert.ok(!playlists.some(item => item.id === createdId)) +})