diff --git a/changelog.md b/changelog.md index 9019f632..ef7ab167 100644 --- a/changelog.md +++ b/changelog.md @@ -1,28 +1,36 @@ # Changelog -## v2.0.3 (2026-09-10) +## v2.1.0 (2026-09-13) ### 🎨 管理后台 UI 现代化全面重构 - **仪表盘全新设计**: 重构仪表盘页面,新增 CPU 与内存实时折线图,支持动态纵轴自适应与数值标注,数据卡片增加趋势指示与动效,整体视觉大幅升级。 - - ![Dashboard UI](md/log/2.0.3-1.png) + - ![Dashboard UI](md/log/2.1.0-1.png) - **WebDAV 同步页面重设计**: 全面重构 WebDAV 同步视图,将操作入口整理为「云端存储协同」与「本地离线备份」两大分组卡片,新增同步状态与健康度面板、同步日志实时推送面板,操作流程更清晰直观。 - - ![WebDAV UI](md/log/2.0.3-2.png) + - ![WebDAV UI](md/log/2.1.0-2.png) - **全局界面优化**: 统一各列表(用户管理、快照管理、数据查看等)的表格与卡片风格,升级系统日志工具栏(自动轮询开关、刷新按钮),移动端侧边栏布局更紧凑。 ### 🌟 新增功能 - **WebDAV 多阶段任务进度与实时日志**: + - 全量备份支持多阶段进度展示(准备 → 打包 → 传输 → 完成),进度条实时联动文件名与上传速率。 - 同步日志通过 SSE 实时推送,新日志自动推入并高亮,无需手动刷新。 - 云端恢复支持弹窗选择「全量 ZIP 快照还原」或「散文件增量恢复」两种模式,并可查看历史快照版本列表。 - - **WebDAV 传输性能优化**: + - HTTP Keep-Alive 连接池复用,减少重复 TCP/TLS 握手开销。 - 文件流缓冲区升级至 1 MB,ZIP 压缩等级调优至 level 4(打包速度提升约 3~4 倍)。 - 散文件并发上传提升至 8 线程,自动递归创建目录防 404,兼容坚果云/群晖/Alist/Nextcloud 等主流 WebDAV 服务。 - - **系统日志自动轮询**: 日志视图新增「自动轮询刷新」开关,激活后每 3 秒自动拉取并智能贴底跟随。 +- **不喜欢 (Dislike) 规则与管理全面升级 (#366 @pyss56)**: + - **前端可视化管理**: 支持在 Web 端直接管理“不喜欢”的歌曲、歌手和专辑,提供完整的规则解析、匹配、增删及缓存管理能力。 + - ![Dislike Web UI](md/log/2.1.0-3.png) + - **自动剔除不喜欢歌曲**: 系统设置中新增了“播放时自动剔除不喜欢歌曲”开关,在替换播放队列时自动过滤被标记为“不喜欢”的内容(主动点击播放的除外)。 + - ![Auto Filter Disliked](md/log/2.1.0-4.png) + - **精准匹配与联动**: 落实专辑维度契约 `{ albumName, singers }`,支持专辑名与歌手双校验;新增评分与不喜欢的双向联动;排行榜中增加“不喜欢”快捷操作。 + - **Subsonic 配置与兼容性增强**: 完善前端配置,新增音质/音源优先级、客户端码率限制与推荐过滤,重构底层鉴权头合并和规则编解码(修复 `@` 分隔符问题),支持歌名后缀归一化,整体提升兼容性与交互体验。 + - ![Subsonic Dislike and Quality Settings](md/log/2.1.0-5.png) ### 🔧 修复与优化 @@ -33,10 +41,10 @@ ### 🌟 新增功能 - **Subsonic 公开排行榜映射 (#348 @pyss56)**: + - **在线排行榜暴露**: 在 Subsonic 协议中将各平台(如 QQ 音乐)的在线排行榜暴露为只读虚拟歌单,歌单统一归属于虚拟用户 `lxserver` 的公开歌单中,支持通过平台设置查看不同平台的榜单数据。 - **设置项扩展**: 在系统设置中新增了独立的“开启公开排行榜映射”开关及“排行榜音源平台”配置,界面采用现代 Tag 标签单选交互,方便一键切换目标数据源。 - ![Subsonic Leaderboard Settings](md/log/2.0.2-2.png) - - **Subsonic 播放缓存能力** : - **播放时触发服务器缓存 (#339 @pyss56)**: 新增 `subsonic.cacheOnPlay` 独立开关,支持在通过 Subsonic 协议播放音乐时,在后台自动将曲目落盘至用户的缓存目录,已缓存的会自动跳过,极大增强离线体验。(注意:开启此项可能产生较大流量) @@ -60,21 +68,26 @@ ### 🔧 修复与优化 - **播放列表回退逻辑修复 (#343)**: + - **搜索与歌单列表回退 UI 刷新**: 修复了在关闭“播放时切换歌单”相关设置后,点击搜索列表或歌单中的单曲播放时,界面播放队列依然错误显示为整个搜索列表/歌单,且当前播放索引被强制置零的 Bug。现在回退默认试听列表时 UI 会正确刷新并精确定位到刚刚播放的新歌。 - **Subsonic 收藏 (红心) 状态全局联动修复**: + - **`getStarred` 接口精准剥离**: 修复了获取已星标收藏列表时,服务端错误地聚合所有自定义歌单的 Bug。现在客户端的“我的收藏”严格镜像服务端的 `love` 列表。 - **Subsonic 封面代理与首页/推荐专辑封面修复 (#344 @pyss56)**: + - **抽取独立封面代理模块 (`coverProxy`)**: 将此前客户端 302 重定向拉取封面的模式重构为服务端代理流式返回,内置并发信号量调度(限制同时回源并发数)、自动失败重试(最多 3 次)与内存缓存降级(`STALE`)保护,彻底解决音流等客户端冷启动并发拉取封面被平台 CDN 封禁导致的封面大面积空白问题。 - **恢复首页/推荐与云端专辑封面**: 新增 `buildAlbumCoverUrl` 根据 mid 动态构造云端封面,并为网易云等无法直接拼接 URL 的专辑接入 SDK 详情兜底拉取首曲封面;同时为推荐接口引入抖动自动重试机制。 - **进程级并发队列与请求防击穿**: 将媒体库专辑/歌手补全封面的并发控制提升至模块进程级,并实现 In-flight Promise 复用,防止多客户端并发请求击穿音源;带缓存容量上限管理,预防内存泄露与 OOM。 - **歌曲命名方式修改与重命名联动修复 (#342)**: + - **修复新命名规则无法选中及保存**: 修复前端 `SETTINGS_UI_MAP` 中 `serverCacheNamingPattern` 的 `normalize` 规则遗漏新命名模式的问题,解决选择“歌手-歌名-音质-专辑”、“歌手-歌名”或“歌名-歌手”后无法选中并自动弹回“简洁”的 Bug。 - **配置持久化与 Docker 升级数据保留优化 (#345)**: + - **配置文件默认迁移至数据卷目录 (`data/config.js`)**: 将服务配置文件的默认持久化路径从容易随容器销毁而丢失的根目录重定向至持久化卷目录 `data/config.js`,彻底解决 Docker/Watchtower 升级或重建容器后通过后台保存的所有设置(密码、路径、用户权限等)静默重置为默认值的 Bug。 - **平滑无感兼容迁移**: 服务启动时若 `data/config.js` 尚不存在,将自动从根目录旧 `config.js` 迁移至 `data/config.js`,无需用户手动修改挂载映射。 - **WebDAV 备份与同步统一**: WebDAV 增量同步与全量打包天然覆盖 `data/config.js`,移除了繁琐的根目录文件特判与转移代码,增强了数据备份的稳定性。 - - **自定义目录与本地音乐异常报错修复 (#347)**: + - **修复 songmid 类型异常导致的崩溃**: 修复了在自定义目录关联歌曲、重新渲染或操作本地音乐列表时,由于极少部分未索引歌曲的 `songmid` 为数字类型从而触发 `item.songmid.includes is not a function` 导致组件或整个页面崩溃、误报权限错误的 Bug。 ### 💡 升级与迁移建议 (Migration Guide) diff --git a/docs/subsonic-status.md b/docs/subsonic-status.md new file mode 100644 index 00000000..19e40910 --- /dev/null +++ b/docs/subsonic-status.md @@ -0,0 +1,86 @@ +# Subsonic 协议支持状态对照 + +> 范围:`src/server/subsonic.ts` 对 Subsonic REST API 的**自定义实现**对照表。 +> 图例:✅ 已实现可用 🟡 占位(返回空 / no-op,兼容客户端握手) ❌ 未实现(返回 `Method not found`) +> 更新:2026-09-10(新增「每日推荐歌曲」接口,见文末) + +--- + +## 一、已实现 / 可用接口 + +| 方法 (method) | 类别 | 状态 | 说明 | +|---|---|---|---| +| `ping` | 系统 | ✅ | 握手 / 存活检测 | +| `getLicense` | 系统 | ✅ | 许可信息 | +| `getScanStatus` | 系统 | 🟡 | 固定返回 `scanning:false` | +| `getMusicFolders` | 浏览 | ✅ | 单一音乐库 | +| `getMusicDirectory` | 浏览 | ✅ | 专辑 / 歌单 / 歌手目录 | +| `getGenres` | 浏览 | ✅ | 流派(来自 QQ 音乐 discovery) | +| `getArtists` | 浏览 | ✅ | 按字母索引的歌手 | +| `getArtist` | 浏览 | ✅ | 歌手详情 | +| `getArtistInfo` / `getArtistInfo2` | 浏览 | ✅ | 歌手信息 | +| `getAlbum` | 浏览 | ✅ | 专辑详情(含歌曲列表) | +| `getAlbumList` / `getAlbumList2` | 浏览 | ✅ | 含 `recommend`/`random`/`newest`/`recent` 推荐专辑逻辑 | +| `getStarred` / `getStarred2` | 浏览 | ✅ | 收藏 | +| `getSong` | 媒体 | ✅ | 单曲信息 | +| `stream` / `download` | 媒体 | ✅ | 在线音频流(按 `source_songmid` 解析) | +| `getCoverArt` | 媒体 | ✅ | 封面代理 | +| `getUser` | 用户 | ✅ | 当前用户 | +| `search` / `search2` / `search3` | 搜索 | ✅ | 本地 + 在线全网搜索 | +| `getPlaylists` / `getPlaylist` | 歌单 | ✅ | | +| `createPlaylist` / `updatePlaylist` / `deletePlaylist` | 歌单 | ✅ | | +| `star` / `unstar` | 收藏 | ✅ | | +| `setRating` | 评分 | ✅ | 评分按用户持久化(0-5,0=清除);每日推荐会排除评分落入「不喜欢」阈值(`subsonic.dislikeRating`,默认 1)的歌曲 | +| `scrobble` | 播放 | 🟡 | no-op(返回成功) | +| `getNowPlaying` | 播放 | 🟡 | 返回空列表 | +| `getLyrics` / `getLyricsBySongId` | 歌词 | ✅ | | +| `getOpenSubsonicExtensions` | 扩展 | ✅ | | +| `getRandomSongs` | 发现 | ✅ | 本地库随机 / 流派随机 | +| `getSongsByGenre` / `getSongsByGenre2` | 发现 | ✅ | 按流派拉取云端歌曲 | +| `getSimilarSongs` / `getSimilarSongs2` | 发现 | ✅ | 同歌手相似 | +| `getTopSongs` | 发现 | ✅ | 歌手热门 | +| `getInternetRadioStations` | 电台 | ✅ | QQ 音乐电台 | +| **`getRecommendedSongs`** | **发现(新增)** | ✅ | **每日推荐歌曲(见第二节)** | +| **`getDailySongs`** | **发现(新增)** | ✅ | **`getRecommendedSongs` 的别名** | +| **`getSongsByTag`** | **发现(新增)** | ✅ | **别名,同样映射为每日推荐**(lx-server 未实现独立按标签检索) | + +--- + +## 二、新增:每日推荐歌曲(2026-09-10 实现) + +### 背景 +此前代码里**完全没有**歌曲级「每日推荐」接口(`getRecommendedSongs` / `getDailySongs` / `getSongsByTag` / `DailySongs` 在 `subsonic.ts` 中均为空,走 default 分支返回 `Method not found`)。仅 `getAlbumList2` 带有「推荐专辑」逻辑(`cachedRecommend → fetchRecommendedAlbums`,专辑级)。 + +### 实现 +- 新增数据源 `src/server/utils/recommendSongs.ts`:`fetchRecommendedSongs(size)` + - 复用已验证的 `fetchRecommendedAlbums('random')` 拿推荐专辑; + - 逐张调用 `musicSdk.tx.extendDetail.getAlbumSongs(mid)` 取出专辑内歌曲; + - 按 `tx_` 去重,按「自然日」做种子洗牌(当天稳定、跨天换批),返回可直接播放的在线歌曲。 + - 全程公开接口、无需登录;QQ 抓取失败时回退到上次成功结果,避免每日推荐空白。 + - **评分过滤**:返回前按请求用户过滤——评分落入「不喜欢」区间(`0 < rating <= subsonic.dislikeRating`,默认阈值为 1,可配置)的歌曲不会进入每日推荐(`setRating` 设置)。 +- `subsonic.ts` 分发开关新增 `case 'getRecommendedSongs' / 'getDailySongs' / 'getSongsByTag'`,handler `handleGetRecommendedSongs` 复用 `renderRandomSongs` 输出(与 `getRandomSongs`/`getSongsByGenre` 同一条渲染链路,封面 / 播放均正常)。 + +### 调用示例 +``` +GET /rest/getRecommendedSongs?u=&p=&f=json&size=20 +GET /rest/getDailySongs?u=&p=&f=json +``` +返回结构:`{ recommendedSongs: { song: [ ... ] } }`(XML 下为 `recommendedSongs > children > song`)。 + +--- + +## 三、未实现(官方标准方法,当前返回 `Method not found`) + +| 方法 | 类别 | +|---|---| +| `getIndexes` | 浏览 | +| `getVideos` | 媒体 | +| `getBookmarks` / `createBookmark` / `deleteBookmark` | 书签 | +| `getPlayQueue` / `savePlayQueue` | 播放队列 | +| `createUser` / `updateUser` / `deleteUser` / `changePassword` / `getAvatar` | 用户管理 | +| `getPodcasts` / `getNewestPodcasts` / `refreshPodcasts` 等播客系列 | 播客 | +| `getChatMessages` / `createChatMessage` | 聊天 | +| `createShare` / `getShares` / `deleteShare` | 分享 | +| `createInternetRadioStation` / `updateInternetRadioStation` / `deleteInternetRadioStation` | 电台管理 | + +> 注:lx-server 定位是「个人音乐库桥接」,未实现多用户管理 / 播客 / 分享 / 聊天等协作类接口属预期范围。 diff --git a/md/log/2.0.3-1.png b/md/log/2.1.0-1.png similarity index 100% rename from md/log/2.0.3-1.png rename to md/log/2.1.0-1.png diff --git a/md/log/2.0.3-2.png b/md/log/2.1.0-2.png similarity index 100% rename from md/log/2.0.3-2.png rename to md/log/2.1.0-2.png diff --git a/md/log/2.1.0-3.png b/md/log/2.1.0-3.png new file mode 100644 index 00000000..3b17a1e3 Binary files /dev/null and b/md/log/2.1.0-3.png differ diff --git a/md/log/2.1.0-4.png b/md/log/2.1.0-4.png new file mode 100644 index 00000000..cbc162e9 Binary files /dev/null and b/md/log/2.1.0-4.png differ diff --git a/md/log/2.1.0-5.png b/md/log/2.1.0-5.png new file mode 100644 index 00000000..b6a6c9c2 Binary files /dev/null and b/md/log/2.1.0-5.png differ diff --git a/package.json b/package.json index 69e741a0..2cf3e649 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "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", + "test:unit": "tsx --test test/*.test.ts", "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 14b72fae..c50a87f8 100644 --- a/public/app.js +++ b/public/app.js @@ -162,6 +162,9 @@ class App { document.querySelector('input[name="subsonic.publicLeaderboards"]')?.addEventListener('change', () => { this.toggleSubsonicLeaderboardVisibility(); }); + document.querySelector('input[name="webdav.enable"]')?.addEventListener('change', () => { + this.toggleWebdavVisibility(); + }); this.initTagSelectors(); // 日志查看 @@ -2412,6 +2415,7 @@ class App { if (form.elements['webdav.enable']) { form.elements['webdav.enable'].checked = config['webdav.enable'] === true; } + this.toggleWebdavVisibility(); if (form.elements['webdav.url']) { form.elements['webdav.url'].value = config['webdav.url'] || ''; } @@ -2487,6 +2491,59 @@ class App { form.elements['subsonic.playCacheFirst'].checked = config['subsonic.playCacheFirst'] !== false; } + // 不喜欢 / 评分联动 + if (form.elements['subsonic.dislikeRating'] !== undefined) { + form.elements['subsonic.dislikeRating'].value = String(config['subsonic.dislikeRating'] ?? 1); + } + if (form.elements['subsonic.hideDisliked']) { + form.elements['subsonic.hideDisliked'].checked = config['subsonic.hideDisliked'] !== false; + } + if (form.elements['subsonic.dislikeCrossSource']) { + form.elements['subsonic.dislikeCrossSource'].checked = config['subsonic.dislikeCrossSource'] === true; + } + if (form.elements['subsonic.dislikeNoRecommend']) { + form.elements['subsonic.dislikeNoRecommend'].checked = config['subsonic.dislikeNoRecommend'] !== false; + } + if (form.elements['subsonic.dislikeDuetMode']) { + form.elements['subsonic.dislikeDuetMode'].value = config['subsonic.dislikeDuetMode'] || 'any'; + } + if (form.elements['subsonic.dislikeNormalizeName']) { + form.elements['subsonic.dislikeNormalizeName'].checked = config['subsonic.dislikeNormalizeName'] !== false; + } + if (form.elements['subsonic.dislikeRequireSinger']) { + form.elements['subsonic.dislikeRequireSinger'].checked = config['subsonic.dislikeRequireSinger'] !== false; + } + if (form.elements['subsonic.linkRatingToDislike']) { + form.elements['subsonic.linkRatingToDislike'].checked = config['subsonic.linkRatingToDislike'] === true; + } + if (form.elements['subsonic.linkDislikeToRating']) { + form.elements['subsonic.linkDislikeToRating'].checked = config['subsonic.linkDislikeToRating'] === true; + } + + // 音质 / 源优选 + if (form.elements['subsonic.quality.enabled']) { + form.elements['subsonic.quality.enabled'].checked = config['subsonic.quality.enabled'] !== false; + } + if (form.elements['subsonic.quality.priority']) { + const qVal = config['subsonic.quality.priority'] || 'flac,320k,128k'; + form.elements['subsonic.quality.priority'].value = qVal; + this.updateQualityPriorityTagUI(qVal); + } + if (form.elements['subsonic.quality.clientCapMode']) { + form.elements['subsonic.quality.clientCapMode'].value = config['subsonic.quality.clientCapMode'] || 'soft'; + } + if (form.elements['subsonic.source.priority']) { + const sVal = config['subsonic.source.priority'] || 'kw,tx,wy,mg,kg'; + form.elements['subsonic.source.priority'].value = sVal; + this.updateSourcePriorityTagUI(sVal); + } + if (form.elements['subsonic.source.crossPlatform']) { + form.elements['subsonic.source.crossPlatform'].checked = config['subsonic.source.crossPlatform'] !== false; + } + if (form.elements['subsonic.source.autoSwitchCustom']) { + form.elements['subsonic.source.autoSwitchCustom'].checked = config['subsonic.source.autoSwitchCustom'] !== false; + } + // 自定义歌曲目录配置 if (form.elements['user.enableCustomMusicDir']) { form.elements['user.enableCustomMusicDir'].checked = config['user.enableCustomMusicDir'] === true; @@ -2500,6 +2557,21 @@ class App { } } + toggleWebdavVisibility() { + const webdavCb = document.querySelector('input[name="webdav.enable"]'); + const childWrapper = document.getElementById('webdav-options'); + const hintWrapper = document.getElementById('webdav-disabled-hint'); + if (webdavCb && childWrapper && hintWrapper) { + if (webdavCb.checked) { + childWrapper.style.display = 'block'; + hintWrapper.style.display = 'none'; + } else { + childWrapper.style.display = 'none'; + hintWrapper.style.display = 'block'; + } + } + } + toggleSubsonicLeaderboardVisibility() { const lbCb = document.querySelector('input[name="subsonic.publicLeaderboards"]'); const childWrapper = document.getElementById('subsonic-leaderboard-options'); @@ -2546,6 +2618,10 @@ class App { }); }); } + + // 初始化音质及源优选拖拽排序标签 + this.updateQualityPriorityTagUI('flac,320k,128k'); + this.updateSourcePriorityTagUI('kw,tx,wy,mg,kg'); } updateLeaderboardSourceTagUI(val) { @@ -2574,6 +2650,207 @@ class App { }); } + renderSortableTagGroup(containerId, hiddenInputName, orderList, labelMap, allItems) { + const container = document.getElementById(containerId); + const hiddenInput = document.querySelector(`input[name="${hiddenInputName}"]`); + if (!container) return; + + // 当前启用的项列表 (按照 orderList 顺序) + const enabledOrder = []; + const seen = new Set(); + (orderList || []).forEach(val => { + val = String(val).trim(); + if (allItems.includes(val) && !seen.has(val)) { + enabledOrder.push(val); + seen.add(val); + } + }); + + // 未在 orderList 中的项为禁用项,排在最后 + const disabledOrder = []; + allItems.forEach(val => { + if (!seen.has(val)) { + disabledOrder.push(val); + } + }); + + // 同步隐藏输入框值(仅启用的项参与配置与优选,逗号分隔) + const syncHiddenInput = () => { + const activeVals = []; + container.querySelectorAll('.tag-sortable-item:not(.disabled)').forEach(el => { + activeVals.push(el.getAttribute('data-value')); + }); + if (hiddenInput) { + hiddenInput.value = activeVals.join(','); + } + }; + + const refreshBadgesAndOrder = () => { + let activeIdx = 1; + container.querySelectorAll('.tag-sortable-item').forEach(el => { + const badge = el.querySelector('.tag-index-badge'); + if (el.classList.contains('disabled')) { + if (badge) badge.textContent = '-'; + el.draggable = false; + } else { + if (badge) badge.textContent = activeIdx++; + el.draggable = true; + } + }); + syncHiddenInput(); + }; + + container.innerHTML = ''; + + const createTagElement = (val, isDisabled) => { + const tag = document.createElement('div'); + tag.className = `tag-select-item tag-sortable-item ${isDisabled ? 'disabled' : 'active'}`; + tag.draggable = !isDisabled; + tag.setAttribute('data-value', val); + tag.innerHTML = ` + - + + + + + + ${labelMap[val] || val} + + + ${isDisabled ? '' : ''} + + + `; + + // 点击整块或点击按钮切换启用/禁用 + tag.addEventListener('click', (e) => { + // 如果是拖拽动作触发的 click,不处理 + if (tag.dataset.wasDragged === 'true') { + delete tag.dataset.wasDragged; + return; + } + + if (tag.classList.contains('disabled')) { + // 从禁用 -> 启用:插入到所有启用项的后面(即第一个 disabled 项前面) + tag.classList.remove('disabled'); + tag.classList.add('active'); + const toggleBtn = tag.querySelector('.tag-toggle-btn'); + if (toggleBtn) { + toggleBtn.title = '点击禁用并移至末尾'; + toggleBtn.innerHTML = ''; + } + const firstDisabled = container.querySelector('.tag-sortable-item.disabled'); + if (firstDisabled && firstDisabled !== tag) { + container.insertBefore(tag, firstDisabled); + } else { + container.appendChild(tag); + } + } else { + // 从启用 -> 禁用:直接移到最后变灰 + // 至少保留一项可用 + const remainingActive = container.querySelectorAll('.tag-sortable-item:not(.disabled)'); + if (remainingActive.length <= 1) { + if (typeof this?.showToast === 'function') { + this.showToast('至少需要保留一个可用项', 'warning'); + } + return; + } + + tag.classList.remove('active'); + tag.classList.add('disabled'); + const toggleBtn = tag.querySelector('.tag-toggle-btn'); + if (toggleBtn) { + toggleBtn.title = '点击启用并加入优选'; + toggleBtn.innerHTML = ''; + } + // 移到末尾 + container.appendChild(tag); + } + refreshBadgesAndOrder(); + }); + + // 拖拽处理 + tag.addEventListener('dragstart', (e) => { + if (tag.classList.contains('disabled')) { + e.preventDefault(); + return; + } + tag.dataset.wasDragged = 'true'; + tag.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', val); + }); + + tag.addEventListener('dragend', () => { + tag.classList.remove('dragging'); + container.querySelectorAll('.tag-sortable-item').forEach(el => el.classList.remove('drag-over')); + refreshBadgesAndOrder(); + setTimeout(() => { delete tag.dataset.wasDragged; }, 100); + }); + + tag.addEventListener('dragover', (e) => { + e.preventDefault(); + const dragging = container.querySelector('.dragging'); + if (!dragging || dragging === tag) return; + // 拖拽只能在启用项之间排序,不能插到禁用项后面 + if (tag.classList.contains('disabled')) { + const firstDisabled = container.querySelector('.tag-sortable-item.disabled'); + if (firstDisabled) { + container.insertBefore(dragging, firstDisabled); + } + return; + } + e.dataTransfer.dropEffect = 'move'; + const rect = tag.getBoundingClientRect(); + const next = (e.clientX - rect.left) > (rect.width / 2); + container.insertBefore(dragging, next ? tag.nextSibling : tag); + }); + + tag.addEventListener('dragenter', (e) => { + e.preventDefault(); + if (!tag.classList.contains('dragging') && !tag.classList.contains('disabled')) { + tag.classList.add('drag-over'); + } + }); + + tag.addEventListener('dragleave', () => { + tag.classList.remove('drag-over'); + }); + + return tag; + }; + + // 先渲染启用项,再渲染禁用项 + enabledOrder.forEach(val => container.appendChild(createTagElement(val, false))); + disabledOrder.forEach(val => container.appendChild(createTagElement(val, true))); + + refreshBadgesAndOrder(); + } + + updateQualityPriorityTagUI(valStr) { + const list = (valStr || '').split(',').map(s => s.trim()).filter(Boolean); + const labelMap = { + 'flac': '无损 (flac)', + '320k': '高品 (320k)', + '128k': '标准 (128k)' + }; + const allItems = ['flac', '320k', '128k']; + this.renderSortableTagGroup('tag-group-quality-priority', 'subsonic.quality.priority', list, labelMap, allItems); + } + + updateSourcePriorityTagUI(valStr) { + const list = (valStr || '').split(',').map(s => s.trim()).filter(Boolean); + const labelMap = { + 'kw': '酷我音乐 (kw)', + 'tx': 'QQ 音乐 (tx)', + 'wy': '网易云 (wy)', + 'mg': '咪咕音乐 (mg)', + 'kg': '酷狗音乐 (kg)' + }; + const allItems = ['kw', 'tx', 'wy', 'mg', 'kg']; + this.renderSortableTagGroup('tag-group-source-priority', 'subsonic.source.priority', list, labelMap, allItems); + } + togglePublicNonAdminAccessVisibility() { const favCb = document.querySelector('input[name="user.enablePublicFavorites"]'); const childWrapper = document.getElementById('public-non-admin-access-wrapper'); @@ -2663,6 +2940,21 @@ class App { 'subsonic.lyricTranslation': formData.get('subsonic.lyricTranslation') === 'on', 'subsonic.cacheOnPlay': formData.get('subsonic.cacheOnPlay') === 'on', 'subsonic.playCacheFirst': formData.get('subsonic.playCacheFirst') === 'on', + 'subsonic.dislikeRating': Number(formData.get('subsonic.dislikeRating') || 1), + 'subsonic.hideDisliked': formData.get('subsonic.hideDisliked') === 'on', + 'subsonic.dislikeCrossSource': formData.get('subsonic.dislikeCrossSource') === 'on', + 'subsonic.dislikeNoRecommend': formData.get('subsonic.dislikeNoRecommend') === 'on', + 'subsonic.dislikeDuetMode': formData.get('subsonic.dislikeDuetMode') || 'any', + 'subsonic.dislikeNormalizeName': formData.get('subsonic.dislikeNormalizeName') === 'on', + 'subsonic.dislikeRequireSinger': formData.get('subsonic.dislikeRequireSinger') === 'on', + 'subsonic.linkRatingToDislike': formData.get('subsonic.linkRatingToDislike') === 'on', + 'subsonic.linkDislikeToRating': formData.get('subsonic.linkDislikeToRating') === 'on', + 'subsonic.quality.enabled': formData.get('subsonic.quality.enabled') === 'on', + 'subsonic.quality.priority': (formData.get('subsonic.quality.priority') || '').trim() || 'flac,320k,128k', + 'subsonic.quality.clientCapMode': formData.get('subsonic.quality.clientCapMode') || 'soft', + 'subsonic.source.priority': (formData.get('subsonic.source.priority') || '').trim() || 'kw,tx,wy,mg,kg', + 'subsonic.source.crossPlatform': formData.get('subsonic.source.crossPlatform') === 'on', + 'subsonic.source.autoSwitchCustom': formData.get('subsonic.source.autoSwitchCustom') === 'on', 'singer.sourcePriority': formData.get('singer.sourcePriority'), 'system.allowUnsafeVM': formData.get('system.allowUnsafeVM') === 'on', }; diff --git a/public/index.html b/public/index.html index bf82a8ab..7f8ebaf3 100644 --- a/public/index.html +++ b/public/index.html @@ -837,47 +837,53 @@

WebDAV 同步配置

-
-
- - -
-
+ @@ -1301,6 +1307,155 @@

Subsonic 协议配置

设置歌手详情、头像及简介的获取来源优先级
+ + +
+
不喜欢 / 评分联动 (Subsonic)
+ +
+ + + Subsonic 客户端打低星时写回原生 dislike 规则的阈值;0 关闭整条联动 +
+
+
+ 在列表中隐藏 dislike 歌曲 + 命中不喜欢规则的歌曲从专辑/歌单/搜索等列表中剔除;关闭则仅以评分体现 +
+ +
+
+
+ dislike 跨平台同名命中 + 开启后同名不同歌手/专辑也会被一并屏蔽(有误伤风险) +
+ +
+
+
+ 推荐类接口排除 dislike + 每日推荐/随机/相似歌曲中排除 dislike 命中歌曲(与隐藏列表独立) +
+ +
+
+ + + 歌曲与专辑维度多歌手时,dislike 规则命中的判定方式(合唱歌曲与专辑歌手集合统一按此处理) +
+
+
+ 歌名去版本后缀归一化 + 「晴天 (Live)」也能命中「晴天」规则(代价:少数仅差后缀的不同歌曲会误并) +
+ +
+
+
+ 要求歌手同时匹配 + 歌曲/专辑级别都要求歌手匹配,避免不同歌手同名歌曲被一起屏蔽 +
+ +
+
+
+ 评星 → 不喜欢 自动联动 + Subsonic 客户端打低星时写回原生 dislike 规则;关闭则评分仅记录、不触碰不喜欢 +
+ +
+
+
+ 不喜欢 → 评星 自动联动 + 网页端/Subsonic 点「不喜欢」回写客户端评分(低星);关闭则不喜欢仅写入规则、不回写评星 +
+ +
+
+
音质 / 源优选 (Subsonic)
+ +
+
+ 音质优选总开关 + 按优先级主动选最优可用音质;关闭则 stream 仅单次解析 +
+ +
+
+ + +
+ 拖拽可调整优先顺序;点击 ✕ 可停用/置底变灰(再次点击即可重新启用) +
+
+ + + 客户端声明最大码率时的取舍策略 +
+
+ + +
+ 拖拽可调整优先顺序;点击 ✕ 可停用/置底变灰(客户端所选源始终最优先) +
+
+
+ 允许跨平台优选 + 按歌名+歌手在其它平台搜索替身 +
+ +
+
+
+ 同源切换其它自定义源 + callUserApiGetMusicUrl 内部循环同平台候选脚本 +
+ +
@@ -1411,12 +1566,14 @@

任务进行中

-

云端同步未开启

+

请先开启 WebDAV 服务

配置 WebDAV 后,即可随时在手机、电脑等多端同步歌单配置,并支持定时自动全量备份,数据安全无忧。

- +
+ +
diff --git a/public/js/config.js b/public/js/config.js index 40902c17..d118df65 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -2,6 +2,6 @@ // 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts) // 服务端拦截 /js/config.js 请求, 读取此处版本号并合并服务端配置后返回 window.CONFIG = { - buildHash: '1d11424', + buildHash: '95513fa', version: 'v2.0.2', }; diff --git a/public/music/app.js b/public/music/app.js index 0d15d3a7..cf235d5b 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -61,6 +61,7 @@ function initGlobalListSearch() { document.addEventListener('DOMContentLoaded', () => { initGlobalListSearch(); + if (window.DislikeManager) window.DislikeManager.load(); }); // Settings & Batch Selection @@ -81,6 +82,7 @@ const DEFAULT_SETTINGS = { lyricFontFamily: '', // 词字体 switchPlaylistOnSearchPlay: true, // 播放搜索歌曲时切换歌单 (默认开启) switchPlaylistOnSongListPlay: true, // 播放歌单/排行榜歌曲时切换歌单 (默认开启) + autoFilterDislikedSongs: true, // 播放列表自动剔除不喜欢歌曲 (默认开启) autoResume: true, // 自动恢复进度 (默认开启) showSidebarSongInfo: false, // 展示侧边栏封面 enableCrossfade: true, // 音频淡入淡出 @@ -609,7 +611,7 @@ window.updateUserUI = updateUserUI; async function checkAndUpdateCustomDirUI() { let userEnableCustomDir = false; let localUserToken = typeof userToken !== 'undefined' ? userToken : localStorage.getItem('lx_user_token'); - + if (localUserToken) { try { const vRes = await fetch('/api/user/auth/verify', { @@ -654,7 +656,7 @@ async function checkAndUpdateCustomDirUI() { } else { switchContainer.classList.add('hidden'); switchContainer.classList.remove('flex'); - + // 如果用户注销,则取消激活状态 if (!isUser || !userEnableCustomDir) { if (customDirSwitch && customDirSwitch.checked) { @@ -1650,7 +1652,10 @@ async function doSearch(page = 1, append = false, prefetch = false) { else if (currentSearchScope === 'lib_albums') targets = window.libraryData.albums; else if (currentSearchScope === 'local_list') { const listId = window.currentViewingListId || 'default'; - if (currentListData) { + if (listId === 'dislike_songs') { + // [Fix] 不喜欢列表从 DislikeManager 读取 + targets = window.DislikeManager?.state.dislikeList || []; + } else if (currentListData) { if (listId === 'default') targets = currentListData.defaultList; else if (listId === 'love') targets = currentListData.loveList; else { @@ -2057,9 +2062,14 @@ function renderSingerResults(list) { + ${singer.name}
@@ -2098,9 +2108,14 @@ function renderAlbumResults(list) { class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"> +
${item.name}
@@ -2233,12 +2248,40 @@ function renderArtistHeader(info, activeTab, order) { btn.className = base + ' ' + (favd ? favedCls : normalCls); btn.title = favd ? '取消收藏' : '收藏歌手'; } + const dBtn = document.getElementById('artist-header-dislike-btn'); + if(dBtn && favd){ + dBtn.className = 'absolute top-2 right-22 md:top-4 md:right-28 w-8 h-8 md:w-10 md:h-10 flex items-center justify-center rounded-full transition-all z-30 shadow-sm active:scale-90 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20 t-text-main'; + dBtn.title = '不喜欢'; + } })()" class="absolute top-2 right-12 md:top-4 md:right-16 w-8 h-8 md:w-10 md:h-10 flex items-center justify-center rounded-full ${isArtistFavorited(info.id, info.source) ? 'bg-rose-500 text-white' : 'bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20 t-text-main'} transition-all z-30 shadow-sm active:scale-90" title="${isArtistFavorited(info.id, info.source) ? '取消收藏' : '收藏歌手'}"> + + + +
`; @@ -2974,6 +3025,9 @@ function getImgUrl(item) { (s.meta && (s.meta.img || s.meta.pic)) || '/music/assets/logo.svg'; } +window.getImgUrl = getImgUrl; + +// Removed duplicate toggleDislikeSong // List search logic is now handled by ListSearch service in list_search.js function renderResults(list) { @@ -3064,7 +3118,10 @@ function renderResults(list) { const isCurrentMatch = window.ListSearch.isCurrentMatch(actualIndexInOriginal); const isSelected = window.selectedItems.has(String(item.id)); + const isDisliked = Boolean(window.DislikeManager && window.DislikeManager.isDisliked(item)); + let rowClass = 'grid grid-cols-12 gap-4 p-3 rounded-xl hover:t-bg-panel group transition-colors cursor-pointer '; + if (isDisliked && window.currentViewingListId !== 'dislike_songs') rowClass += 'opacity-40 grayscale hover:opacity-80 transition-opacity '; if (isCurrentMatch) rowClass += 'search-current '; else if (isMatched) rowClass += 'search-match '; if (isSelected) rowClass += 'row-selected ring-1 ring-emerald-500/30 '; @@ -3162,7 +3219,12 @@ function renderResults(list) { title="添加到歌单"> - ${currentSearchScope !== 'network' ? ` + + ${currentSearchScope !== 'network' && window.currentViewingListId !== 'dislike_songs' ? ` - @@ -8713,7 +9055,7 @@ function renderLibraryArtists(list) { window.renderLibraryArtists = renderLibraryArtists; /** 渲染收藏专辑列表(带批量操作支持) */ -function renderLibraryAlbums(list) { +function renderLibraryAlbums(list, isDislike = false) { const container = document.getElementById('search-results'); const header = document.getElementById('search-results-header'); const paginationBar = document.getElementById('search-pagination-bar'); @@ -8728,32 +9070,33 @@ function renderLibraryAlbums(list) { container.innerHTML = `
-

还没有收藏任何专辑

-

在搜索结果中点击 ♥ 收藏专辑

+

${isDislike ? '还没有不喜欢任何专辑' : '还没有收藏任何专辑'}

+

在搜索结果中点击 ${isDislike ? '🚫 不喜欢专辑' : '♥ 收藏专辑'}

`; return; } container.innerHTML = `
- 收藏专辑 ${list.length} + ${isDislike ? '不喜欢专辑' : '收藏专辑'} ${list.length}
+ ${!isDislike ? ` + ` : ''}
- ` : ` +
+ +
`} ${item.name}
@@ -8923,25 +9273,42 @@ function updateLibArtistBatchCount() { } async function libDeleteSelectedArtists() { if (window.libraryBatchSelected.size === 0) { showInfo('请先选择要删除的歌手'); return; } + const isDislike = getCurrentActiveListId() === 'dislike_artists'; if (window.isViewingPublicFavorites || !isUserLoggedIn()) { - if (!(await requireAdminForOpenWrite('删除公开收藏歌手'))) return; + if (!(await requireAdminForOpenWrite(isDislike ? '删除公开不喜欢歌手' : '删除公开收藏歌手'))) return; } - const confirmed = await showSelect('删除收藏歌手', `确定删除选中的 ${window.libraryBatchSelected.size} 位歌手吗?`, { danger: true }); + const confirmed = await showSelect(isDislike ? '取消不喜欢歌手' : '删除收藏歌手', `确定${isDislike ? '取消不喜欢' : '删除'}选中的 ${window.libraryBatchSelected.size} 位歌手吗?`, { danger: true }); if (!confirmed) return; - window.libraryData.artists = window.libraryData.artists.filter(a => !window.libraryBatchSelected.has(String(a.id))); - await saveLibraryArtists(); - exitLibraryArtistBatch(); - renderLibraryArtists(window.libraryData.artists); - showSuccess('已删除所选歌手'); + + if (isDislike) { + window.dislikeLibraryData.artists = window.dislikeLibraryData.artists.filter(a => !window.libraryBatchSelected.has(String(a.id))); + await saveDislikeLibraryArtists(); + exitLibraryArtistBatch(); + renderLibraryArtists(window.dislikeLibraryData.artists, true); + showSuccess('已取消不喜欢所选歌手'); + } else { + window.libraryData.artists = window.libraryData.artists.filter(a => !window.libraryBatchSelected.has(String(a.id))); + await saveLibraryArtists(); + exitLibraryArtistBatch(); + renderLibraryArtists(window.libraryData.artists); + showSuccess('已删除所选歌手'); + } } -async function removeLibraryArtist(id, source) { +async function removeLibraryArtist(id, source, isDislike = false) { if (window.isViewingPublicFavorites || !isUserLoggedIn()) { - if (!(await requireAdminForOpenWrite('删除公开收藏歌手'))) return; + if (!(await requireAdminForOpenWrite(isDislike ? '删除公开不喜欢歌手' : '删除公开收藏歌手'))) return; + } + if (isDislike) { + window.dislikeLibraryData.artists = window.dislikeLibraryData.artists.filter(a => !(String(a.id) === String(id) && a.source === source)); + await saveDislikeLibraryArtists(); + renderLibraryArtists(window.dislikeLibraryData.artists, true); + showInfo('已取消不喜欢'); + } else { + window.libraryData.artists = window.libraryData.artists.filter(a => !(String(a.id) === String(id) && a.source === source)); + await saveLibraryArtists(); + renderLibraryArtists(window.libraryData.artists); + showInfo('已取消收藏'); } - window.libraryData.artists = window.libraryData.artists.filter(a => !(String(a.id) === String(id) && a.source === source)); - await saveLibraryArtists(); - renderLibraryArtists(window.libraryData.artists); - showInfo('已取消收藏'); } window.enterLibraryArtistBatch = enterLibraryArtistBatch; window.exitLibraryArtistBatch = exitLibraryArtistBatch; @@ -8999,25 +9366,42 @@ function updateLibAlbumBatchCount() { } async function libDeleteSelectedAlbums() { if (window.libraryBatchSelected.size === 0) { showInfo('请先选择要删除的专辑'); return; } + const isDislike = getCurrentActiveListId() === 'dislike_albums'; if (window.isViewingPublicFavorites || !isUserLoggedIn()) { - if (!(await requireAdminForOpenWrite('删除公开收藏专辑'))) return; + if (!(await requireAdminForOpenWrite(isDislike ? '删除公开不喜欢专辑' : '删除公开收藏专辑'))) return; } - const confirmed = await showSelect('删除收藏专辑', `确定删除选中的 ${window.libraryBatchSelected.size} 张专辑吗?`, { danger: true }); + const confirmed = await showSelect(isDislike ? '取消不喜欢专辑' : '删除收藏专辑', `确定${isDislike ? '取消不喜欢' : '删除'}选中的 ${window.libraryBatchSelected.size} 张专辑吗?`, { danger: true }); if (!confirmed) return; - window.libraryData.albums = window.libraryData.albums.filter(a => !window.libraryBatchSelected.has(String(a.id))); - await saveLibraryAlbums(); - exitLibraryAlbumBatch(); - renderLibraryAlbums(window.libraryData.albums); - showSuccess('已删除所选专辑'); + + if (isDislike) { + window.dislikeLibraryData.albums = window.dislikeLibraryData.albums.filter(a => !window.libraryBatchSelected.has(String(a.id))); + await saveDislikeLibraryAlbums(); + exitLibraryAlbumBatch(); + renderLibraryAlbums(window.dislikeLibraryData.albums, true); + showSuccess('已取消不喜欢所选专辑'); + } else { + window.libraryData.albums = window.libraryData.albums.filter(a => !window.libraryBatchSelected.has(String(a.id))); + await saveLibraryAlbums(); + exitLibraryAlbumBatch(); + renderLibraryAlbums(window.libraryData.albums); + showSuccess('已删除所选专辑'); + } } -async function removeLibraryAlbum(id, source) { +async function removeLibraryAlbum(id, source, isDislike = false) { if (window.isViewingPublicFavorites || !isUserLoggedIn()) { - if (!(await requireAdminForOpenWrite('删除公开收藏专辑'))) return; + if (!(await requireAdminForOpenWrite(isDislike ? '删除公开不喜欢专辑' : '删除公开收藏专辑'))) return; + } + if (isDislike) { + window.dislikeLibraryData.albums = window.dislikeLibraryData.albums.filter(a => !(String(a.id) === String(id) && a.source === source)); + await saveDislikeLibraryAlbums(); + renderLibraryAlbums(window.dislikeLibraryData.albums, true); + showInfo('已取消不喜欢'); + } else { + window.libraryData.albums = window.libraryData.albums.filter(a => !(String(a.id) === String(id) && a.source === source)); + await saveLibraryAlbums(); + renderLibraryAlbums(window.libraryData.albums); + showInfo('已取消收藏'); } - window.libraryData.albums = window.libraryData.albums.filter(a => !(String(a.id) === String(id) && a.source === source)); - await saveLibraryAlbums(); - renderLibraryAlbums(window.libraryData.albums); - showInfo('已取消收藏'); } window.enterLibraryAlbumBatch = enterLibraryAlbumBatch; @@ -9800,7 +10184,7 @@ function buildPlaylistExport(listId, exportedAt = new Date().toISOString()) { if (listId === 'default') { playlist = { id: 'default', name: '默认列表', source: 'lxserver', list: currentListData.defaultList || [] }; } else if (listId === 'love') { - playlist = { id: 'love', name: '我的收藏', source: 'lxserver', list: currentListData.loveList || [] }; + playlist = { id: 'love', name: '我的喜爱', source: 'lxserver', list: currentListData.loveList || [] }; } else { playlist = currentListData.userList?.find(item => item.id === listId); } @@ -9903,6 +10287,7 @@ function initFavoriteSidebarSortable(container) { Sortable.create(container, { animation: 150, handle: '.favorite-sidebar-drag-handle', + filter: '.ignore-sort', ghostClass: 'opacity-50', chosenClass: 'bg-emerald-50', onEnd: () => { @@ -10023,7 +10408,8 @@ function renderMyLists(data) { sidebarItems.push({ id: 'default', type: 'system', el: createItem('default', '默认列表', 'fa-list', data.defaultList.length) }); } if (data.loveList) { - sidebarItems.push({ id: 'love', type: 'system', el: createItem('love', '我的收藏', 'fa-heart', data.loveList.length) }); + // 内层 ♥ 喜爱列表,命名为「我的喜爱」以与外层「我的收藏」折叠面板区分 + sidebarItems.push({ id: 'love', type: 'system', el: createItem('love', '我的喜爱', 'fa-heart', data.loveList.length) }); } if (data.userList) { data.userList.forEach(l => { @@ -10033,6 +10419,8 @@ function renderMyLists(data) { } getOrderedFavoriteSidebarItems(sidebarItems).forEach(item => container.appendChild(item.el)); + + // Removed old dislike injection refreshLibrarySidebarCount(); initFavoriteSidebarSortable(container); refreshFavoritesChildrenHeight(); @@ -10081,7 +10469,10 @@ function handleListClick(listId, skipAutoUpdate = false, preservePage = false) { title = '默认列表'; } else if (listId === 'love') { list = currentListData.loveList; - title = '我的收藏'; + title = '我的喜爱'; + } else if (listId === 'dislike_songs') { + list = window.DislikeManager?.state.dislikeList || []; + title = '不喜欢歌曲'; } else { const uList = currentListData.userList.find(l => l.id === listId); if (uList) { @@ -10119,10 +10510,19 @@ function handleListClick(listId, skipAutoUpdate = false, preservePage = false) { el.classList.remove('active-tab', 'text-emerald-600'); el.classList.add('t-text-muted'); }); - const favTab = document.getElementById('tab-favorites'); - if (favTab) { - favTab.classList.add('active-tab'); - favTab.classList.remove('t-text-muted'); + + if (listId === 'dislike_songs') { + const favTab = document.getElementById('tab-favorites'); + if (favTab) { + favTab.classList.add('active-tab'); + favTab.classList.remove('t-text-muted'); + } + } else { + const favTab = document.getElementById('tab-favorites'); + if (favTab) { + favTab.classList.add('active-tab'); + favTab.classList.remove('t-text-muted'); + } } // Highlight Child List @@ -10168,6 +10568,124 @@ function handleFavoritesClick() { toggleFavorites(); } +function handleDislikeFolderClick() { + exitListSecondaryModes(); + const children = document.getElementById('dislike-children'); + const arrow = document.getElementById('dislike-arrow'); + + // Highlight Header + document.querySelectorAll('[id^="tab-"]').forEach(el => { + el.classList.remove('active-tab', 'text-emerald-600'); + el.classList.add('t-text-muted'); + }); + const favTab = document.getElementById('tab-favorites'); + if (favTab) { + favTab.classList.add('active-tab'); + favTab.classList.remove('t-text-muted'); + } + + if (children.classList.contains('hidden') || children.style.height === '0px') { + children.classList.remove('hidden'); + children.style.height = 'auto'; + const targetHeight = children.scrollHeight; + children.style.height = '0px'; + requestAnimationFrame(() => { + children.style.height = targetHeight + 'px'; + setTimeout(refreshFavoritesChildrenHeight, 300); + }); + arrow.classList.add('rotate-90'); + // Auto select dislike songs when opening + handleDislikeSongsClick(); + } else { + children.style.height = '0px'; + setTimeout(() => { + children.classList.add('hidden'); + refreshFavoritesChildrenHeight(); + }, 300); + arrow.classList.remove('rotate-90'); + } +} + +function handleDislikeSongsClick() { + handleListClick('dislike_songs'); +} + +function handleDislikeArtistsClick() { + exitListSecondaryModes(); + window.currentViewingListId = 'dislike_artists'; + currentSearchScope = 'local_list'; + + // Highlight Header + document.querySelectorAll('[id^="tab-"]').forEach(el => { + el.classList.remove('active-tab', 'text-emerald-600'); + el.classList.add('t-text-muted'); + }); + const favTab = document.getElementById('tab-favorites'); + if (favTab) { + favTab.classList.add('active-tab'); + favTab.classList.remove('t-text-muted'); + } + + document.querySelectorAll('[data-sidebar-list-id]').forEach(el => { + el.classList.remove('active-sub-item'); + el.classList.add('t-text-muted'); + }); + const item = document.querySelector(`[data-sidebar-list-id="dislike_artists"]`); + if (item) { + item.classList.add('active-sub-item'); + item.classList.remove('t-text-muted'); + } + + if (window.innerWidth < 1025) { + const sidebar = document.getElementById('main-sidebar'); + if (sidebar && !sidebar.classList.contains('-translate-x-full')) { + toggleSidebar(); + } + } + + if (typeof renderLibraryArtists === 'function') { + renderLibraryArtists(window.dislikeLibraryData.artists, true); // pass flag for dislike + } +} + +function handleDislikeAlbumsClick() { + exitListSecondaryModes(); + window.currentViewingListId = 'dislike_albums'; + currentSearchScope = 'local_list'; + + // Highlight Header + document.querySelectorAll('[id^="tab-"]').forEach(el => { + el.classList.remove('active-tab', 'text-emerald-600'); + el.classList.add('t-text-muted'); + }); + const favTab = document.getElementById('tab-favorites'); + if (favTab) { + favTab.classList.add('active-tab'); + favTab.classList.remove('t-text-muted'); + } + + document.querySelectorAll('[data-sidebar-list-id]').forEach(el => { + el.classList.remove('active-sub-item'); + el.classList.add('t-text-muted'); + }); + const item = document.querySelector(`[data-sidebar-list-id="dislike_albums"]`); + if (item) { + item.classList.add('active-sub-item'); + item.classList.remove('t-text-muted'); + } + + if (window.innerWidth < 1025) { + const sidebar = document.getElementById('main-sidebar'); + if (sidebar && !sidebar.classList.contains('-translate-x-full')) { + toggleSidebar(); + } + } + + if (typeof renderLibraryAlbums === 'function') { + renderLibraryAlbums(window.dislikeLibraryData.albums, true); + } +} + async function handleCreateList() { const name = await showInput("新建歌单", "请输入新歌单的名称:", { placeholder: "歌单名称" @@ -11773,7 +12291,7 @@ function renderPlaylistAddGrid() { // 1. My Love const loveList = activeListData.loveList || []; const isLoved = !isBatch && targetId && loveList.some(s => s.id === targetId); - listContainer.appendChild(createGridItem('love', '我的收藏', loveList.length, isLoved)); + listContainer.appendChild(createGridItem('love', '我的喜爱', loveList.length, isLoved)); // 2. User Lists if (activeListData.userList) { diff --git a/public/music/index.html b/public/music/index.html index 582e9a40..f2cc8985 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -89,6 +89,11 @@

LX MUSIC

  • +
    @@ -111,6 +116,62 @@

    LX MUSIC

    新建歌单
  • +
  • +
    +
  • +
  • +
    +
    + + 不喜欢 +
    + +
    + +
  • +
  • +
    +
  • @@ -2113,7 +2174,8 @@

    本地缓存与重置

    - + @@ -2833,6 +2895,31 @@

  • +
    +
    +
    +

    + 播放时自动剔除不喜欢歌曲 + + + + 开启:在播放搜索列表、歌单、排行榜并替换播放队列时,自动从列表中剔除被标记为“不喜欢”的歌曲(主动点击播放的和主动播放不喜欢的除外)。
    + 关闭:不剔除不喜欢歌曲。 +
    +
    +

    +
    + +
    +
    @@ -5366,6 +5453,7 @@

    项目使用声明< + diff --git a/public/music/js/batch_pagination.js b/public/music/js/batch_pagination.js index 08f48634..5b156673 100644 --- a/public/music/js/batch_pagination.js +++ b/public/music/js/batch_pagination.js @@ -157,27 +157,31 @@ function updateBatchToolbar() { deleteBtn.classList.add('hidden'); } else { deleteBtn.classList.remove('hidden'); + const isDislike = typeof getCurrentActiveListId === 'function' && getCurrentActiveListId() === 'dislike_songs'; + deleteBtn.innerHTML = isDislike ? '' : ''; + deleteBtn.title = isDislike ? '批量取消' : '批量删除'; } } } async function batchDeleteFromList() { + const activeListId = typeof getCurrentActiveListId === 'function' ? getCurrentActiveListId() : null; + const isDislike = activeListId === 'dislike_songs'; + if (window.selectedItems.size === 0) { - showError('请先选择要删除的歌曲'); + showError(isDislike ? '请先选择要取消不喜欢的歌曲' : '请先选择要删除的歌曲'); return; } - if (!(await showSelect('批量删除', `确定要删除选中的 ${window.selectedItems.size} 首歌曲吗?`, { danger: true }))) { + if (!(await showSelect(isDislike ? '批量取消' : '批量删除', `确定要${isDislike ? '取消不喜欢' : '删除'}选中的 ${window.selectedItems.size} 首歌曲吗?`, { danger: true }))) { return; } // 公开列表删除需要管理员权限 if (typeof requireAdminForOpenWrite === 'function') { - if (!(await requireAdminForOpenWrite('删除公开列表中的歌曲'))) return; + if (!(await requireAdminForOpenWrite(isDislike ? '删除公开不喜欢列表中的歌曲' : '删除公开列表中的歌曲'))) return; } - // Get current list context - const activeListId = getCurrentActiveListId(); if (!activeListId || !currentListData) { showError('无法确定当前列表'); return; @@ -185,6 +189,45 @@ async function batchDeleteFromList() { const idsToDelete = Array.from(window.selectedItems); + if (activeListId === 'dislike_songs') { + if (window.DislikeManager) { + // [Fix] 先把要删除的歌曲对象收集好,避免循环中列表变动导致索引错乱 + const songsToRemove = Array.from(window.selectedSongObjects.values()) + .filter(s => idsToDelete.includes(String(s.id))); + // 如果 selectedSongObjects 里没找到,兜底从 viewingPlaylist 中按 ID 查找 + if (songsToRemove.length === 0 && window.viewingPlaylist) { + const idSet = new Set(idsToDelete); + window.viewingPlaylist.forEach(s => { + if (idSet.has(String(s.id))) songsToRemove.push(s); + }); + } + let successCount = 0; + for (const song of songsToRemove) { + try { + await window.DislikeManager.removeSong(song); + successCount++; + } catch (e) { + console.error('[Batch Dislike Remove] Failed for:', song.name, e); + } + } + // [Fix] 一次性刷新 UI,而不是每首歌都触发重新渲染 + window.viewingPlaylist = window.DislikeManager.state.dislikeList || []; + if (typeof renderResults === 'function') { + renderResults(window.viewingPlaylist); + } + if (typeof refreshDislikeSidebarCount === 'function') { + refreshDislikeSidebarCount(); + } + if (typeof window.showToast === 'function' && successCount > 0) { + window.showToast('success', `已批量取消 ${successCount} 首歌曲的不喜欢`); + } + if (typeof exitBatchMode === 'function') { + exitBatchMode(); + } + } + return; + } + if (window.SyncManager.mode === 'local') { // Token authentication is sufficient; a saved plaintext password is not required. const authHeaders = getUserAuthHeaders(); diff --git a/public/music/js/dislike_manager.js b/public/music/js/dislike_manager.js new file mode 100644 index 00000000..03699f24 --- /dev/null +++ b/public/music/js/dislike_manager.js @@ -0,0 +1,223 @@ +/** + * Dislike Manager for LX Music Web + * 不喜欢管理模块 + * + * 与 Subsonic 评分联动,读写同一份 lx-music 原生 dislike 规则: + * 歌名@歌手 → 精确屏蔽一首歌 + * 歌名 → 屏蔽所有同名(翻唱 / 多版本) + * @歌手 → 屏蔽该歌手全部歌曲 + * !<专辑名>@<歌手> → 屏蔽整张专辑 + * + * 规则由服务端统一解析(/api/music/dislike),这里只做前端判定与增删, + * 归一化规则与 src/modules/dislike/match.ts 保持一致。 + */ +window.DislikeManager = (function () { + const API_BASE = '/api/music/dislike'; + + const state = { + initialized: false, + loading: false, + exact: new Set(), + musicNames: new Set(), + singerNames: new Set(), + albums: [], // [{ albumName, singers: [] }] + // 匹配选项由服务端下发,保证前后端判定一致 + crossSource: false, + duetMode: 'any', // any | all | primary + normalizeName: true, + requireSinger: true, + dislikeList: [], // Added to hold the JSON objects for the UI list + }; + + /** 归一化:与服务端一致(@ → #、去空格、小写) */ + const normalize = (v) => String(v ?? '').replaceAll('@', '#').trim().toLowerCase(); + + /** 拆分歌手:「A、B」「A,B」「A/B」「A feat.B」「A & B」 */ + const splitSingers = (singer) => + String(singer ?? '') + .split(/[、,,\/]|\s*(?:feat\.?|ft\.?|featuring|&|;)\s*/i) + .map((s) => normalize(s)) + .filter(Boolean); + + /** + * 歌名归一化:剥离常见版本后缀,与服务端 normalizeSongName 保持一致。 + * 「晴天 (Live)」→「晴天」 + */ + // 与 src/server/utils/songVersion.ts 的 VERSION_SUFFIX_RE 保持一致:支持括号内与无括号连字符两种形式 + const VERSION_SUFFIX_RE = + /(?:[\s\-–—_]*[((](?:live|remix|现场|伴奏|纯音乐|demo|翻唱|acoustic|instrumental|off\s*vocal|版)[^))]*[))]|[\s]*[-–—]\s*(?:live|remix|现场|伴奏|纯音乐|demo|翻唱|acoustic|instrumental|off\s*vocal))\s*$/i; + const normalizeSongName = (v) => normalize(v).replace(VERSION_SUFFIX_RE, '').trim(); + + const getAlbumName = (song) => song?.albumName ?? song?.meta?.albumName ?? song?.album ?? ''; + + async function request(url, payload) { + if (typeof ensureUserAuthToken === 'function') { + await ensureUserAuthToken(); + } + const headers = { ...(typeof getUserAuthHeaders === 'function' ? getUserAuthHeaders() : {}) }; + if (payload) headers['Content-Type'] = 'application/json'; + let res = await fetch(url, { + method: payload ? 'POST' : 'GET', + headers, + body: payload ? JSON.stringify(payload) : undefined, + }); + if (res.status === 401 && typeof ensureUserAuthToken === 'function') { + const refreshed = await ensureUserAuthToken({ force: true }); + if (refreshed) { + const newHeaders = { ...(typeof getUserAuthHeaders === 'function' ? getUserAuthHeaders() : {}) }; + if (payload) newHeaders['Content-Type'] = 'application/json'; + res = await fetch(url, { + method: payload ? 'POST' : 'GET', + headers: newHeaders, + body: payload ? JSON.stringify(payload) : undefined, + }); + } + } + if (res.status === 401) { + const err = new Error('请先登录本地账号'); + err.status = 401; + err.isAuthError = true; + throw err; + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } + + function applyData(data, options) { + state.exact = new Set(data?.exact || []); + state.musicNames = new Set(data?.musicNames || []); + state.singerNames = new Set(data?.singerNames || []); + state.albums = data?.albums || []; + state.dislikeList = data?.dislikeList || []; + if (options) { + state.crossSource = !!options.crossSource; + state.duetMode = options.duetMode || 'any'; + state.normalizeName = options.normalizeName !== false; + state.requireSinger = options.requireSinger !== false; + } + state.initialized = true; + } + + /** 拉取规则(已加载过则直接返回,force=true 强制刷新) */ + async function load(force) { + if (state.initialized && !force) return state; + if (state.loading) return state; + state.loading = true; + try { + const r = await request(API_BASE); + if (r && r.success) applyData(r.data, r.options); + } catch (e) { + console.error('[Dislike] load failed:', e); + } finally { + state.loading = false; + } + return state; + } + + /** 判定一首歌是否命中不喜欢规则 */ + function isDisliked(song) { + if (!song) return false; + const name = normalize(song.name); + const allSingers = splitSingers(song.singer); + // primary 模式只看主唱(第一位歌手) + const singers = state.duetMode === 'primary' ? allSingers.slice(0, 1) : allSingers; + // 候选歌名:原名 + 去后缀名(去重),提升 Live / Remix 版本召回 + const candidateNames = Array.from( + new Set([name, state.normalizeName ? normalizeSongName(song.name) : ''].filter(Boolean)) + ); + + // 1. 专辑维度(专辑名 + 歌手 双重校验) + // 服务端 serializeDislikeRules 仅返回 { albumName, singers },故按专辑名匹配 + const albumName = getAlbumName(song); + if (albumName) { + const an = normalize(albumName); + for (const alb of state.albums) { + if (normalize(alb.albumName ?? '') !== an) continue; + if (alb.singers && alb.singers.length) { + if (!singers.length) { + // 歌曲没有歌手信息:requireSinger 时无法确认,保守放过 + if (state.requireSinger) continue; + } else { + const albumSingers = alb.singers.map((s) => + state.normalizeName ? normalizeSongName(s) : normalize(s) + ); + const hit = singers.some((x) => + albumSingers.some((a) => x === a || x.includes(a) || a.includes(x)) + ); + if (!hit) continue; + } + } + return true; + } + } + + // 2. 歌手维度 + if (singers.length) { + if (state.duetMode === 'all') { + // all:所有歌手都被屏蔽才算命中,避免牵连合作者 + if (singers.every((s) => state.singerNames.has(s))) return true; + } else { + for (const s of singers) { + if (state.singerNames.has(s)) return true; + } + } + } + + // 3. 歌曲维度(歌名 + 歌手 双重校验) + for (const n of candidateNames) { + if (!n) continue; + // 纯歌名规则:requireSinger 时不单独命中,避免同名歌被误杀 + if (state.musicNames.has(n) && !state.requireSinger) return true; + if (singers.length) { + if (state.duetMode === 'all') { + if (singers.every((s) => state.exact.has(`${n}@${s}`))) return true; + } else { + for (const s of singers) { + if (state.exact.has(`${n}@${s}`)) return true; + } + } + } + } + + return false; + } + + async function mutate(action, payload) { + const r = await request(`${API_BASE}/${action}`, payload); + if (r && r.success) applyData(r.data, r.options); + return !!(r && r.success); + } + + const addSong = (song) => mutate('add', { ...song, type: 'song' }); + const removeSong = (song) => mutate('remove', { ...song, type: 'song' }); + const addSinger = (singer) => mutate('add', { type: 'singer', singer: singer || '' }); + const removeSinger = (singer) => mutate('remove', { type: 'singer', singer: singer || '' }); + const addAlbum = (song) => + mutate('add', { type: 'album', albumName: getAlbumName(song), singer: song?.singer || '' }); + const removeAlbum = (song) => + mutate('remove', { type: 'album', albumName: getAlbumName(song), singer: song?.singer || '' }); + + /** 切换某首歌的不喜欢状态,返回切换后的最终不喜欢状态布尔值 */ + async function toggleSong(song) { + await load(); + const willDislike = !isDisliked(song); + const success = willDislike ? await addSong(song) : await removeSong(song); + if (!success) throw new Error('操作未成功完成'); + return willDislike; + } + + return { + load, + isDisliked, + addSong, + removeSong, + addSinger, + removeSinger, + addAlbum, + removeAlbum, + toggleSong, + get state() { + return state; + }, + }; +})(); diff --git a/public/music/js/leaderboard_manager.js b/public/music/js/leaderboard_manager.js index 91160c68..6d020f22 100644 --- a/public/music/js/leaderboard_manager.js +++ b/public/music/js/leaderboard_manager.js @@ -27,6 +27,9 @@ window.LeaderboardManager = (function () { if (initialized) return; initialized = true; + // 预加载 dislike 规则,供歌曲行的「不喜欢」按钮判定显示状态 + if (window.DislikeManager) window.DislikeManager.load(); + // 优先从缓存读取 const cachedSource = localStorage.getItem('lb-source-select'); state.source = cachedSource || 'wy'; @@ -192,7 +195,10 @@ window.LeaderboardManager = (function () { const isMatched = window.ListSearch && window.ListSearch.isMatched(index); const isCurrentMatch = window.ListSearch && window.ListSearch.isCurrentMatch(index); + const isDisliked = Boolean(window.DislikeManager && window.DislikeManager.isDisliked(song)); + let rowClass = 'grid grid-cols-12 gap-2 md:gap-4 p-3 rounded-xl hover:t-bg-panel group transition-colors cursor-pointer '; + if (isDisliked) rowClass += 'opacity-40 grayscale hover:opacity-80 transition-opacity '; if (isCurrentMatch) rowClass += 'search-current '; else if (isMatched) rowClass += 'search-match '; if (isSelected) rowClass += 'row-selected ring-1 ring-emerald-500/30 '; @@ -267,6 +273,11 @@ window.LeaderboardManager = (function () { onclick="event.stopPropagation(); window.LeaderboardManager.addSongToPlaylist(${index})"> + `; @@ -483,6 +494,36 @@ window.LeaderboardManager = (function () { renderPagination(); }, + /** + * 切换某首歌的「不喜欢」状态。 + * 走 /api/music/dislike,与 Subsonic 评分联动共用同一份规则。 + */ + dislikeSong: async function (index) { + const song = state.songs[index]; + if (!song) return; + const songObj = { + ...song, + source: song.source || state.source + }; + if (typeof toggleDislikeSong === 'function') { + await toggleDislikeSong(songObj); + this.renderSongs(); + } else if (window.DislikeManager) { + try { + const nowDisliked = await window.DislikeManager.toggleSong(songObj); + this.renderSongs(); + if (typeof window.showToast === 'function') { + window.showToast('success', nowDisliked ? '已加入不喜欢' : '已移出不喜欢'); + } + } catch (e) { + console.error('[Leaderboard] dislike failed:', e); + if (typeof window.showToast === 'function') { + window.showToast('error', e.message || '操作失败'); + } + } + } + }, + resetLocalPage: function () { state.localPage = 1; }, diff --git a/public/music/js/single_song_ops.js b/public/music/js/single_song_ops.js index 32a8170f..6b285f87 100644 --- a/public/music/js/single_song_ops.js +++ b/public/music/js/single_song_ops.js @@ -197,6 +197,16 @@ async function deleteSingleSong(songId) { return; } + if (activeListId === 'dislike_songs') { + const songToToggle = window.viewingPlaylist ? window.viewingPlaylist.find(s => String(s.id) === String(songId)) : null; + if (songToToggle && typeof window.toggleDislikeSong === 'function') { + await window.toggleDislikeSong(songToToggle); + } else { + showError('无法找到此不喜欢歌曲'); + } + return; + } + if (window.SyncManager.mode === 'local') { // Token authentication is sufficient; a saved plaintext password is not required. const authHeaders = getUserAuthHeaders(); diff --git a/public/music/js/songlist_manager.js b/public/music/js/songlist_manager.js index ba42e503..6709569c 100644 --- a/public/music/js/songlist_manager.js +++ b/public/music/js/songlist_manager.js @@ -43,6 +43,7 @@ window.SongListManager = (function () { renderSortTabs(); await loadTags(); + if (window.DislikeManager) window.DislikeManager.load(); loadList(); // Bind events that might not be in HTML attributes @@ -456,11 +457,14 @@ window.SongListManager = (function () { const isMatched = window.ListSearch.isMatched(index); const isCurrentMatch = window.ListSearch.isCurrentMatch(index); + const isDisliked = Boolean(window.DislikeManager && window.DislikeManager.isDisliked(song)); + // Highlight Logic: // - Current Match: Strong border and subtle background // - Matched: Subtle background // - Selected: Theme background (will be defined in CSS) let rowClass = 'grid grid-cols-12 gap-4 p-3 rounded-xl hover:t-bg-panel group transition-colors cursor-pointer '; + if (isDisliked) rowClass += 'opacity-40 grayscale hover:opacity-80 transition-opacity '; if (isCurrentMatch) rowClass += 'search-current '; else if (isMatched) rowClass += 'search-match '; if (isSelected) rowClass += 'row-selected ring-1 ring-emerald-500/30 '; @@ -529,6 +533,11 @@ window.SongListManager = (function () { onclick="event.stopPropagation(); window.SongListManager.addSongToPlaylist(${index})"> + `}).join(''); @@ -619,6 +628,31 @@ window.SongListManager = (function () { source: song.source || detailState.source }); }, + dislikeSong: async function (index) { + const song = detailState.list[index]; + if (!song) return; + const songObj = { + ...song, + source: song.source || detailState.source + }; + if (typeof toggleDislikeSong === 'function') { + await toggleDislikeSong(songObj); + renderDetail(); + } else if (window.DislikeManager) { + try { + const nowDisliked = await window.DislikeManager.toggleSong(songObj); + renderDetail(); + if (typeof window.showToast === 'function') { + window.showToast('success', nowDisliked ? '已加入不喜欢' : '已移出不喜欢'); + } + } catch (e) { + console.error('[SongList] dislike failed:', e); + if (typeof window.showToast === 'function') { + window.showToast('error', e.message || '操作失败'); + } + } + } + }, playAll: function () { if (detailState.list.length === 0) return; if (typeof window.updatePlaylist === 'function') { diff --git a/public/music/sw.js b/public/music/sw.js index fb8fed2d..1203a827 100644 --- a/public/music/sw.js +++ b/public/music/sw.js @@ -19,6 +19,7 @@ const ASSETS_TO_CACHE = [ './js/songlist_manager.js', './js/list_search.js', './js/leaderboard_manager.js', + './js/dislike_manager.js', './js/local_music.js', './js/download_manager.js', './js/common_ui.js', diff --git a/public/style.css b/public/style.css index d4d64f62..980feba1 100644 --- a/public/style.css +++ b/public/style.css @@ -1759,8 +1759,95 @@ body { transform: scale(1.1); } +/* 可拖拽/可排序 Tag 样式 */ +.tag-sortable-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 6px; + min-height: 38px; + padding: 4px; + border-radius: 8px; + transition: background-color 0.2s ease; +} + +.tag-sortable-item { + cursor: grab; + touch-action: none; +} +.tag-sortable-item:active { + cursor: grabbing; +} + +.tag-sortable-item .tag-index-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + font-size: 0.72rem; + font-weight: 700; + margin-right: 2px; +} + +.tag-sortable-item .tag-drag-icon { + opacity: 0.7; + margin-right: -2px; +} + +.tag-sortable-item.dragging { + opacity: 0.45; + transform: scale(0.96); +} +.tag-sortable-item.drag-over { + border-color: var(--accent-primary, #3b82f6); + box-shadow: 0 0 0 2px var(--accent-primary, #3b82f6); +} + +/* 禁用/未启用状态的 Tag(灰色置底) */ +.tag-sortable-item.disabled { + background: rgba(255, 255, 255, 0.04); + border: 1px dashed rgba(255, 255, 255, 0.15); + color: var(--text-tertiary, #64748b); + box-shadow: none; + opacity: 0.65; +} + +.tag-sortable-item.disabled:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-secondary, #94a3b8); + opacity: 0.9; +} + +.tag-sortable-item.disabled .tag-index-badge { + background: rgba(255, 255, 255, 0.08); + color: var(--text-tertiary, #64748b); +} + +.tag-sortable-item .tag-toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 4px; + padding: 2px; + border-radius: 50%; + cursor: pointer; + transition: transform 0.15s ease, background-color 0.15s ease; +} + +.tag-sortable-item .tag-toggle-btn:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.15); +} + +.tag-sortable-item.disabled .tag-toggle-btn:hover { + background: rgba(255, 255, 255, 0.12); +} /* =============== 日志查看 =============== */ .logs-content { diff --git a/src/constants.ts b/src/constants.ts index d0d1573f..24baaa09 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -46,6 +46,12 @@ export const ENV_PARAMS = [ 'SUBSONIC_LYRIC_TRANSLATION', 'SUBSONIC_CACHE_ON_PLAY', 'SUBSONIC_PLAY_CACHE_FIRST', + 'SUBSONIC_QUALITY_ENABLED', + 'SUBSONIC_QUALITY_PRIORITY', + 'SUBSONIC_QUALITY_CLIENT_CAP_MODE', + 'SUBSONIC_SOURCE_PRIORITY', + 'SUBSONIC_SOURCE_CROSS_PLATFORM', + 'SUBSONIC_SOURCE_AUTOSWITCH_CUSTOM', 'ARTIST_MAX_FETCH_PAGES', 'CACHE_NAMING_PATTERN', 'SYSTEM_ALLOW_UNSAFE_VM', diff --git a/src/defaultConfig.ts b/src/defaultConfig.ts index e899b10a..c425910a 100644 --- a/src/defaultConfig.ts +++ b/src/defaultConfig.ts @@ -67,9 +67,25 @@ const config: LX.Config = { 'subsonic.onlineSearchSources': 'wy,tx,kw,kg,mg', // 在线搜索默认平台 'subsonic.publicLeaderboards': false, // 是否在 Subsonic 中公开在线排行榜(只读虚拟播放列表) 'subsonic.leaderboardSource': 'tx', // 在线排行榜平台: tx | wy | kg | kw | mg + 'subsonic.dislikeRating': 1, // 评分联动 dislike 阈值: 0 < rating <= 该值 视为不喜欢(写回原生 dislike 规则); 设为 0 关闭联动 + 'subsonic.linkRatingToDislike': false, // 解耦开关(正向): 评星 -> 不喜欢 是否自动联动; false=不联动(仅记录评分) + 'subsonic.linkDislikeToRating': false, // 解耦开关(反向): 不喜欢 -> 评星 是否自动联动; false=不联动(仅记录不喜欢) + 'subsonic.hideDisliked': true, // 是否在 Subsonic 列表中隐藏 dislike 命中的歌曲(关闭则只评分不剔除) + 'subsonic.dislikeCrossSource': false, // dislike 是否跨平台同名命中(各平台 ID 不互通, 开启会误伤同名歌手/专辑) + 'subsonic.dislikeNoRecommend': true, // 推荐类接口(每日推荐/随机/相似)是否排除 dislike 歌曲(与 hideDisliked 独立) + 'subsonic.dislikeDuetMode': 'any', // 合唱歌曲匹配模式: any=任一歌手命中 / all=全部命中 / primary=仅主唱 + 'subsonic.dislikeNormalizeName': true, // 歌名去版本后缀归一化: 「晴天 (Live)」也能命中「晴天」规则 + 'subsonic.dislikeRequireSinger': true, // 歌曲/专辑级别都要求歌手同时匹配(关闭则纯歌名规则可单独命中, 会误杀同名) + 'subsonic.recommendPoolSize': 100, // 推荐池容量: 专辑列表可翻页数 ≈ 容量 / size 'subsonic.lyricTranslation': true, // 是否在 Subsonic 歌词中包含翻译 'subsonic.cacheOnPlay': false, // 是否在 Subsonic 播放时触发服务器缓存保存(落盘) 'subsonic.playCacheFirst': true, // 是否在 Subsonic 播放时优先使用服务器已有的本地缓存/下载文件直接传输 + 'subsonic.quality.enabled': true, // 音质优选总开关: 按优先级主动选最优可用音质(非失败降级); 关闭则 stream 仅单次解析 + 'subsonic.quality.priority': 'flac,320k,128k', // 音质优先级(从高到低, 逗号分隔), 按此顺序主动优选 + 'subsonic.quality.clientCapMode': 'soft', // 客户端 maxBitrate 上界模式: hard 仅选≤上限的最高优先级; soft 上限内都取不到再突破上限选更高优先级 + 'subsonic.source.priority': 'kw,tx,wy,mg,kg', // 跨平台优选顺序(逗号分隔, 客户端所选源始终优先) + 'subsonic.source.crossPlatform': true, // 是否允许跨平台优选(按歌名+歌手在其它平台搜索替身) + 'subsonic.source.autoSwitchCustom': true, // 同源是否切换其它自定义源脚本(callUserApiGetMusicUrl 内部循环同平台候选脚本) 'singer.sourcePriority': ['tx', 'wy'], // 歌手信息源优先级 'artist.maxFetchPages': 20, // 歌手歌曲最大抓取页数 'cache.namingPattern': 'simple', // 缓存命名规则 diff --git a/src/index.ts b/src/index.ts index a5ab9d5a..04906128 100644 --- a/src/index.ts +++ b/src/index.ts @@ -352,6 +352,25 @@ if (envParams.SUBSONIC_CACHE_ON_PLAY !== undefined) { if (envParams.SUBSONIC_PLAY_CACHE_FIRST !== undefined) { setBoolConfig('subsonic.playCacheFirst', envParams.SUBSONIC_PLAY_CACHE_FIRST) } +if (envParams.SUBSONIC_QUALITY_ENABLED !== undefined) { + setBoolConfig('subsonic.quality.enabled', envParams.SUBSONIC_QUALITY_ENABLED) +} +if (envParams.SUBSONIC_QUALITY_PRIORITY) { + global.lx.config['subsonic.quality.priority'] = envParams.SUBSONIC_QUALITY_PRIORITY.split(',').map((s: string) => s.trim()).filter(Boolean).join(',') +} +if (envParams.SUBSONIC_QUALITY_CLIENT_CAP_MODE) { + const mode = envParams.SUBSONIC_QUALITY_CLIENT_CAP_MODE as any + if (mode === 'hard' || mode === 'soft') global.lx.config['subsonic.quality.clientCapMode'] = mode +} +if (envParams.SUBSONIC_SOURCE_PRIORITY) { + global.lx.config['subsonic.source.priority'] = envParams.SUBSONIC_SOURCE_PRIORITY.split(',').map((s: string) => s.trim()).filter(Boolean).join(',') +} +if (envParams.SUBSONIC_SOURCE_CROSS_PLATFORM !== undefined) { + setBoolConfig('subsonic.source.crossPlatform', envParams.SUBSONIC_SOURCE_CROSS_PLATFORM) +} +if (envParams.SUBSONIC_SOURCE_AUTOSWITCH_CUSTOM !== undefined) { + setBoolConfig('subsonic.source.autoSwitchCustom', envParams.SUBSONIC_SOURCE_AUTOSWITCH_CUSTOM) +} if (envParams.ARTIST_MAX_FETCH_PAGES) { const pages = parseInt(envParams.ARTIST_MAX_FETCH_PAGES, 10) if (!isNaN(pages) && pages > 0) global.lx.config['artist.maxFetchPages'] = pages diff --git a/src/modules/dislike/dislikeDataManage.ts b/src/modules/dislike/dislikeDataManage.ts index bdd2c279..b2138cfc 100644 --- a/src/modules/dislike/dislikeDataManage.ts +++ b/src/modules/dislike/dislikeDataManage.ts @@ -1,38 +1,120 @@ +import fs from 'node:fs' +import path from 'node:path' import { SPLIT_CHAR } from '@/constants' import { type SnapshotDataManage } from './snapshotDataManage' -import { filterRules } from './utils' - -const filterRulesToString = (rules: string) => { - return Array.from(filterRules(rules)).join('\n') -} +import { filterRules, encodeAlbumRule } from './utils' export class DislikeDataManage { snapshotDataManage: SnapshotDataManage - dislikeRules = '' + dislikeRules: LX.Dislike.DislikeListData = { dislikeList: [] } constructor(snapshotDataManage: SnapshotDataManage) { this.snapshotDataManage = snapshotDataManage - let dislikeRules: LX.Dislike.DislikeRules | null + try { + const latest = this.snapshotDataManage.snapshotInfo?.latest + if (latest) { + const filePath = path.join(this.snapshotDataManage.snapshotDir, `snapshot_${latest}`) + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, 'utf-8').trim() + if (content.startsWith('{')) { + const parsed = JSON.parse(content) + this.dislikeRules = parsed + // normalize: populate missing dislikeRule fields from name/singer + this.normalizeRules() + } + } + } + } catch { /* ignore */ } + void this.snapshotDataManage.getSnapshotInfo().then(async(snapshotInfo) => { - if (snapshotInfo.latest) dislikeRules = await this.snapshotDataManage.getSnapshot(snapshotInfo.latest) - if (!dislikeRules) dislikeRules = '' - this.dislikeRules = dislikeRules + if (!snapshotInfo.latest) return + const data = await this.snapshotDataManage.getSnapshot(snapshotInfo.latest) + if (data) { + this.dislikeRules = data + this.normalizeRules() + } }) } + /** + * Build the canonical rule string for a DislikeSongInfo entry. + * Snapshot entries loaded from disk may not have dislikeRule populated + * (they only have name/singer from the original music object). + * Format: "name@singer" | "@singer" | "name" + */ + private buildDislikeRule(item: LX.Dislike.DislikeSongInfo): string { + if (item.dislikeRule) return item.dislikeRule + const name = (item.name ?? '').trim() + const singer = (item.singer ?? '').trim() + if (name && singer) return `${name}${SPLIT_CHAR.DISLIKE_NAME}${singer}` + if (singer) return `${SPLIT_CHAR.DISLIKE_NAME}${singer}` + return name + } + + /** + * Ensure every entry in dislikeList has dislikeRule populated. + * Called after loading from snapshot so getDislikeRulesString works correctly. + */ + private normalizeRules() { + for (const item of this.dislikeRules.dislikeList) { + if (!item.dislikeRule) { + item.dislikeRule = this.buildDislikeRule(item) + } + } + } + + // Returns the structured object getDislikeRules = async(): Promise => { return this.dislikeRules } - addDislikeInfo = async(infos: LX.Dislike.DislikeMusicInfo[]) => { - this.dislikeRules = filterRulesToString(this.dislikeRules + '\n' + infos.map(info => `${info.name ?? ''}${SPLIT_CHAR.DISLIKE_NAME}${info.singer ?? ''}`).join('\n')) + // Returns all active rule strings separated by newline (used by dislikeCache for parsing) + getDislikeRulesString = (): string => { + return this.dislikeRules.dislikeList + .map(s => this.buildDislikeRule(s)) + .filter(Boolean) + .join('\n') + } + + addDislikeInfo = async(infos: LX.Dislike.DislikeSongInfo[]) => { + // Check for duplicates + const currentRules = new Set(this.dislikeRules.dislikeList.map(s => this.buildDislikeRule(s))) + for (const info of infos) { + const rule = this.buildDislikeRule(info) + if (!rule) continue + if (!currentRules.has(rule)) { + info.dislikeRule = rule + this.dislikeRules.dislikeList.push(info) + currentRules.add(rule) + } + } return this.dislikeRules } - overwirteDislikeInfo = async(rules: string) => { - this.dislikeRules = filterRulesToString(rules) + addDislikeAlbums = async(infos: LX.Dislike.DislikeAlbumInfo[]) => { + if (!infos || infos.length === 0) return this.dislikeRules + const currentRules = new Set(this.dislikeRules.dislikeList.map(s => this.buildDislikeRule(s))) + const lines = infos.map(info => encodeAlbumRule(info.albumName, info.singer)) + + for (let i = 0; i < lines.length; i++) { + const rule = lines[i] + if (!currentRules.has(rule)) { + this.dislikeRules.dislikeList.push({ + name: '', + singer: '', + dislikeRule: rule + }) + currentRules.add(rule) + } + } return this.dislikeRules } -} + overwirteDislikeInfo = async(rulesString: string) => { + // Called when removing items: keep only entries whose rule key is still in the string + const validRules = new Set(filterRules(rulesString)) + this.dislikeRules.dislikeList = this.dislikeRules.dislikeList.filter(item => validRules.has(this.buildDislikeRule(item))) + return this.dislikeRules + } +} diff --git a/src/modules/dislike/event.ts b/src/modules/dislike/event.ts index 715b0724..6bd80f89 100644 --- a/src/modules/dislike/event.ts +++ b/src/modules/dislike/event.ts @@ -38,7 +38,9 @@ export class DislikeEvent extends EventEmitter { */ async dislike_data_overwrite(userName: string, dislikeData: LX.Dislike.DislikeRules, isRemote: boolean = false) { const userSpace = getUserSpace(userName) - await userSpace.dislikeManage.dislikeDataManage.overwirteDislikeInfo(dislikeData) + // Convert structured DislikeListData back to rule-string for the overwrite filter + const rulesString = dislikeData.dislikeList.map(s => s.dislikeRule ?? '').filter(Boolean).join('\n') + await userSpace.dislikeManage.dislikeDataManage.overwirteDislikeInfo(rulesString) this.emit('dislike_data_overwrite', userName, dislikeData, isRemote) dislikeUpdated() } @@ -50,7 +52,7 @@ export class DislikeEvent extends EventEmitter { * @param addMusicLocationType 添加在到列表的位置 * @param isRemote 是否属于远程操作 */ - async dislike_music_add(userName: string, musicInfo: LX.Dislike.DislikeMusicInfo[], isRemote: boolean = false) { + async dislike_music_add(userName: string, musicInfo: LX.Dislike.DislikeSongInfo[], isRemote: boolean = false) { const userSpace = getUserSpace(userName) // const changedIds = await userSpace.dislikeManage.dislikeDataManage.addDislikeInfo(musicInfo) diff --git a/src/modules/dislike/manage.ts b/src/modules/dislike/manage.ts index 2c157fc3..52294ce8 100644 --- a/src/modules/dislike/manage.ts +++ b/src/modules/dislike/manage.ts @@ -14,7 +14,7 @@ export class DislikeManage { createSnapshot = async() => { const listData = await this.getDislikeRules() - const md5 = toMD5(listData.trim()) + const md5 = toMD5(JSON.stringify(listData)) const snapshotInfo = await this.snapshotDataManage.getSnapshotInfo() console.log(md5, snapshotInfo.latest) if (snapshotInfo.latest == md5) return md5 diff --git a/src/modules/dislike/match.ts b/src/modules/dislike/match.ts new file mode 100644 index 00000000..ddf0bc76 --- /dev/null +++ b/src/modules/dislike/match.ts @@ -0,0 +1,223 @@ +import { SPLIT_CHAR } from '@/constants' +import { parseAlbumRule } from './utils' +import { normalizeText, normalizeSongName } from '@/server/utils/songVersion' + +/** + * dislike 规则匹配(lxserver 通用) + * + * 面向 Subsonic / 推荐 / web 等所有消费方,统一判定一首歌是否命中不喜欢规则。 + * 三个维度:歌曲(歌名@歌手)、歌手(@歌手)、专辑(!:@)。 + */ + +export { normalizeText, normalizeSongName } + +/** 拆分歌手字段:「A、B」「A,B」「A/B」「A feat.B」「A & B」 */ +export const splitSingers = (singer: unknown): string[] => + String(singer ?? '') + .split(/[、,,\/]|\s*(?:feat\.?|ft\.?|featuring|&|;)\s*/i) + .map(s => normalizeText(s)) + .filter(Boolean) + +export interface DislikeRuleSet { + /** 精确规则:歌名@歌手 */ + exact: Set + /** 纯歌名规则 */ + musicNames: Set + /** 纯歌手规则:@歌手 */ + singerNames: Set + /** 专辑规则:专辑名(归一化) -> 该专辑歌手集合(归一化) */ + albums: Map> +} + +/** + * 解析规则串(服务端专用)。 + * 与 filterRules 的区别:这里会把专辑规则(! 前缀)单独分离出来, + * 而不是混进歌曲规则里。 + */ +export function parseDislikeRules(rules: string): DislikeRuleSet { + const set: DislikeRuleSet = { + exact: new Set(), + musicNames: new Set(), + singerNames: new Set(), + albums: new Map(), + } + + for (const raw of String(rules || '').split('\n')) { + const line = raw.trim() + if (!line) continue + + // 专辑维度(!<专辑名>@<歌手>,按歌手多条,聚合到专辑名 -> 歌手集合) + const album = parseAlbumRule(line) + if (album) { + const name = normalizeText(album.albumName) + const singer = normalizeText(album.singer) + if (!name) continue + let s = set.albums.get(name) + if (!s) { s = new Set(); set.albums.set(name, s) } + if (singer) s.add(singer) + continue + } + + // 歌曲 / 歌手维度 + const [name, singer] = line.split(SPLIT_CHAR.DISLIKE_NAME) + const n = normalizeText(name) + const s = normalizeText(singer) + // 额外登记「去后缀」版本,让「晴天 (Live)」也能命中「晴天」规则(反之亦然)。 + // 开关关闭时匹配端不会用归一化名,这些额外 key 自然不会被命中。 + if (n && s) { + set.exact.add(`${n}${SPLIT_CHAR.DISLIKE_NAME}${s}`) + const nn = normalizeSongName(n) + if (nn && nn !== n) set.exact.add(`${nn}${SPLIT_CHAR.DISLIKE_NAME}${s}`) + // 多歌手场景:规则整串存储为「歌名@A、B」,但匹配端 splitSingers 后 + // 逐个歌手检查 exact.has('歌名@A'),所以这里也要拆分登记单歌手条目。 + const parts = splitSingers(singer) + if (parts.length > 1) { + for (const ps of parts) { + set.exact.add(`${n}${SPLIT_CHAR.DISLIKE_NAME}${ps}`) + if (nn && nn !== n) set.exact.add(`${nn}${SPLIT_CHAR.DISLIKE_NAME}${ps}`) + } + } + } else if (n) { + set.musicNames.add(n) + const nn = normalizeSongName(n) + if (nn && nn !== n) set.musicNames.add(nn) + } else if (s) { + set.singerNames.add(s) + } + } + + return set +} + +export interface DislikeMatchInput { + name?: string + singer?: string + source?: string + albumId?: string | number + albumName?: string + singerId?: string | number +} + +export interface DislikeMatchOptions { + /** + * 跨平台同名是否也算命中(默认 false)。 + * 由于各平台歌手/专辑 ID 互不相通,且无映射表, + * 开启后同名不同歌手、同名不同专辑会被一并屏蔽(有误伤风险)。 + * + * 注意:只有带平台信息的维度(目前是专辑)能真正区分; + * 歌手 / 歌曲规则只存名字、无平台信息,因此天然跨平台命中。 + */ + crossSource?: boolean + + /** + * 多歌手匹配模式(合唱歌曲与专辑维度共用,如「A、B」): + * - any 任一位歌手命中即屏蔽(默认,最积极,合辑友好) + * - all 所有歌手都命中才屏蔽(最保守,避免误伤合作者) + * - primary 只看第一位(主唱)歌手 + */ + duetMode?: 'any' | 'all' | 'primary' + + /** + * 歌名是否做「去版本后缀」归一化(默认 true)。 + * 让「晴天 (Live)」也能命中「晴天」规则,提升召回。 + */ + normalizeName?: boolean + + /** + * 歌曲 / 专辑级别是否都要求歌手同时匹配(默认 true)。 + * 开启后,只记了歌名、没记歌手的纯歌名规则不会单独命中, + * 避免不同歌手的同名歌曲被一起屏蔽;专辑同理。 + */ + requireSinger?: boolean +} + +/** + * 判定是否命中不喜欢规则。 + * + * 匹配顺序(严格 → 宽松): + * 1. 专辑:source + albumId 精确匹配,并用歌手做二次校验 + * (挡住不同歌手的同名专辑,如两张都叫《同名专辑》) + * 2. 歌手:@歌手 规则 + * 3. 歌曲:歌名@歌手 精确规则,纯歌名规则 + */ +export function isDisliked( + input: DislikeMatchInput, + set: DislikeRuleSet, + options: DislikeMatchOptions = {}, +): boolean { + const crossSource = options.crossSource === true + const duetMode = options.duetMode || 'any' + const normalizeName = options.normalizeName !== false + const requireSinger = options.requireSinger !== false + + const name = normalizeText(input.name) + const allSingers = splitSingers(input.singer) + // primary 模式只看主唱(第一位歌手) + const singers = duetMode === 'primary' ? allSingers.slice(0, 1) : allSingers + const source = normalizeText(input.source) + // 候选歌名:原名 + 去后缀名(去重),提升对 Live / Remix 等版本的召回 + const candidateNames = Array.from(new Set( + [name, normalizeName ? normalizeSongName(input.name) : ''].filter(Boolean) + )) + + // 1. 专辑维度(专辑名 + 歌手集合,复用 duetMode 决定多歌手判定方式) + if (input.albumName) { + const albumName = normalizeText(input.albumName) + const albumSingers = set.albums.get(albumName) + if (albumSingers && albumSingers.size > 0) { + if (singers.length === 0) { + // 歌曲没有歌手信息:无法确认歌手集合关系,保守放过(避免误杀) + } else if (duetMode === 'all') { + // all:歌曲全部歌手都落在专辑歌手集合内才屏蔽(不被合作者误伤) + if (singers.every(s => albumSingers.has(s))) return true + } else { + // any / primary:歌曲任一歌手(primary 仅主唱)命中专辑歌手集合即屏蔽(合辑友好) + if (singers.some(s => albumSingers.has(s))) return true + } + } + } + + // 2. 歌手维度 + if (singers.length > 0) { + if (duetMode === 'all') { + // all:所有歌手都被屏蔽才算命中,避免牵连合作者 + if (singers.every(s => set.singerNames.has(s))) return true + } else { + for (const s of singers) { + if (set.singerNames.has(s)) return true + } + } + } + + // 3. 歌曲维度(歌名 + 歌手 双重校验) + for (const n of candidateNames) { + if (!n) continue + + // 纯歌名规则(只记了歌名、没记歌手): + // requireSinger 时不单独命中,避免不同歌手的同名歌曲被一起屏蔽 + if (set.musicNames.has(n) && !requireSinger) return true + + if (singers.length > 0) { + if (duetMode === 'all') { + if (singers.every(s => set.exact.has(`${n}${SPLIT_CHAR.DISLIKE_NAME}${s}`))) return true + } else { + for (const s of singers) { + if (set.exact.has(`${n}${SPLIT_CHAR.DISLIKE_NAME}${s}`)) return true + } + } + } + } + + return false +} + +/** 批量过滤 */ +export function filterDisliked( + items: T[], + set: DislikeRuleSet, + toInput: (item: T) => DislikeMatchInput, + options: DislikeMatchOptions = {}, +): T[] { + if (!items || items.length === 0) return items + return items.filter(item => !isDisliked(toInput(item), set, options)) +} diff --git a/src/modules/dislike/snapshotDataManage.ts b/src/modules/dislike/snapshotDataManage.ts index 833c04e6..6a69f13f 100644 --- a/src/modules/dislike/snapshotDataManage.ts +++ b/src/modules/dislike/snapshotDataManage.ts @@ -80,7 +80,14 @@ export class SnapshotDataManage { const filePath = path.join(this.snapshotDir, `snapshot_${name}`) let listData: LX.Dislike.DislikeRules try { - listData = (await fs.promises.readFile(filePath)).toString('utf-8') + const content = (await fs.promises.readFile(filePath)).toString('utf-8') + // If it starts with { it is likely JSON, otherwise it's old string format (we don't write compatibility code as requested, but we should at least parse JSON properly) + if (content.trim().startsWith('{')) { + listData = JSON.parse(content) + } else { + // Fallback for old format if someone encounters it, though user said no compatibility needed. Let's just create an empty list. + listData = { dislikeList: [] } + } } catch (err) { syncLog.warn(err) return null @@ -88,11 +95,11 @@ export class SnapshotDataManage { return listData } - saveSnapshot = async(name: string, data: string) => { + saveSnapshot = async(name: string, data: LX.Dislike.DislikeRules) => { syncLog.info('saveSnapshot', this.userDataManage.userName, name) const filePath = path.join(this.snapshotDir, `snapshot_${name}`) try { - fs.writeFileSync(filePath, data) + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)) } catch (err) { syncLog.error(err) throw err diff --git a/src/modules/dislike/utils.ts b/src/modules/dislike/utils.ts index 96c94718..25644344 100644 --- a/src/modules/dislike/utils.ts +++ b/src/modules/dislike/utils.ts @@ -1,9 +1,59 @@ import { SPLIT_CHAR } from '@/constants' +import { normalizeText } from '@/server/utils/songVersion' +// ───────────────────────────────────────────── +// 专辑维度规则(lxserver 扩展) +// 格式:!<专辑名>@<歌手> +// +// 按歌手拆成多条存储(一张合辑若有 A/B/C 三位歌手,则写 3 行)。 +// 用 ! 前缀与「歌手(@歌手)」「歌曲(歌名@歌手)」维度区分; +// 歌手恒在 @ 末尾,故解析按最后一个 @ 切,专辑名可含 @。 +// ───────────────────────────────────────────── +export const ALBUM_RULE_PREFIX = '!' + +/** 生成一条专辑规则行(!<专辑名>@<歌手>)。按歌手拆多条时多次调用。 */ +export const encodeAlbumRule = (albumName: string, singer: string): string => { + return `${ALBUM_RULE_PREFIX}${albumName}${SPLIT_CHAR.DISLIKE_NAME}${singer ?? ''}` +} + +/** 是否专辑规则行 */ +export const isAlbumRule = (line: string): boolean => line.startsWith(ALBUM_RULE_PREFIX) + +/** + * 解析专辑规则行,非专辑规则返回 null。 + * 新格式:!<专辑名>@<歌手>(按最后一个 @ 切,歌手恒在末尾)。 + */ +export const parseAlbumRule = (line: string): { albumName: string, singer: string } | null => { + if (!isAlbumRule(line)) return null + const body = line.slice(1) + const idx = body.lastIndexOf(SPLIT_CHAR.DISLIKE_NAME) + if (idx < 0) return null + const albumName = normalizeText(body.slice(0, idx).trim()) + const singer = normalizeText(body.slice(idx + 1).trim()) + if (!albumName) return null + return { albumName, singer } +} + +/** + * 规整并去重不喜欢规则串。 + * 歌曲维度做「歌名@歌手」归一化;专辑维度(! 前缀)先解析再重新编码, + * 避免把分隔符 @ 误写成 # 导致专辑规则失效且无法删除。 + * @param rules 原始规则串(换行分隔) + * @returns 归一化后的规则集合(已去重) + */ export const filterRules = (rules: string) => { const list: string[] = [] for (const item of rules.split('\n')) { - if (!item) continue + const line = item.trim() + if (!line) continue + // 专辑规则(!<专辑名>@<歌手>):按歌手拆分存储,专辑名可能含 @,不走歌曲规则的 @ 切分。 + // 必须「先解析再重新编码」:直接对整行 normalizeText 会把分隔符 @ 替换成 #, + // 导致 parseAlbumRule 无法识别该行、删除侧也无法精确匹配。统一成 !<归一化专辑名>@<归一化歌手>。 + if (line.startsWith(ALBUM_RULE_PREFIX)) { + const parsed = parseAlbumRule(line) + list.push(parsed ? encodeAlbumRule(parsed.albumName, parsed.singer) : normalizeText(line)) + continue + } let [name, singer] = item.split(SPLIT_CHAR.DISLIKE_NAME) if (name) { name = name.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() diff --git a/src/server/server.ts b/src/server/server.ts index da156574..e745436b 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -14,6 +14,10 @@ import { SYNC_CLOSE_CODE, } from '@/constants' import { getUserSpace, releaseUserSpace, getUserName, getServerId, getUserDirname, getUserConfig, migrateUserData, renameUserSpace, finishRenameUserSpace } from '@/user' +import { parseDislikeRules, splitSingers } from '@/modules/dislike/match' +import { encodeAlbumRule } from '@/modules/dislike/utils' +import { normalizeText } from '@/server/utils/songVersion' +import { invalidateDislikeCache } from '@/server/utils/dislikeCache' import { createMsg2call } from 'message2call' import { ElFinderConnector, getSystemRoot } from './elfinderConnector' import formidable from 'formidable' @@ -31,6 +35,65 @@ import crypto from 'node:crypto' import needle from 'needle' const { MusicTagger, MetaPicture } = require('music-tag-native') +/** 当前生效的 dislike 匹配选项,下发给前端保证前后端判定一致 */ +const dislikeMatchOptions = () => ({ + crossSource: global.lx.config['subsonic.dislikeCrossSource'] === true, + duetMode: (global.lx.config['subsonic.dislikeDuetMode'] || 'any') as 'any' | 'all' | 'primary', + normalizeName: global.lx.config['subsonic.dislikeNormalizeName'] !== false, + requireSinger: global.lx.config['subsonic.dislikeRequireSinger'] !== false, +}) + +/** 把 dislike 规则集转成可 JSON 序列化的结构(Set/Map → Array,同时聚合 dislike/library 下的 albums 和 artists) */ +const serializeDislikeRules = (rules: string, username?: string) => { + const parsed = parseDislikeRules(rules) + if (username) { + try { + const uDir = getUserDirname(username) + const dLibDir = path.join(global.lx.userPath, uDir, 'dislike', 'library') + // Merge disliked albums from library/albums.json + const albumsFile = path.join(dLibDir, 'albums.json') + if (fs.existsSync(albumsFile)) { + const albumsArr = JSON.parse(fs.readFileSync(albumsFile, 'utf8')) + if (Array.isArray(albumsArr)) { + for (const x of albumsArr) { + const albumName = normalizeText(String(x.name || '')) + if (!albumName) continue + let sSet = parsed.albums.get(albumName) + if (!sSet) { + sSet = new Set() + parsed.albums.set(albumName, sSet) + } + const singers = splitSingers(x.artistName) + for (const s of singers) sSet.add(s) + } + } + } + // Merge disliked artists from library/artists.json + const artistsFile = path.join(dLibDir, 'artists.json') + if (fs.existsSync(artistsFile)) { + const artistsArr = JSON.parse(fs.readFileSync(artistsFile, 'utf8')) + if (Array.isArray(artistsArr)) { + for (const x of artistsArr) { + const singerName = normalizeText(String(x.name || '')) + if (singerName) parsed.singerNames.add(singerName) + } + } + } + } catch (e: any) { + console.warn('[Dislike] Error merging dislike library:', e.message) + } + } + return { + exact: Array.from(parsed.exact), + musicNames: Array.from(parsed.musicNames), + singerNames: Array.from(parsed.singerNames), + albums: Array.from(parsed.albums.entries()).map(([albumName, singers]) => ({ + albumName, + singers: Array.from(singers), + })), + } +} + // ===== Player Session Store ===== const playerSessions = new Map() const SESSION_TTL = 24 * 60 * 60 * 1000 // 24小时 @@ -1316,7 +1379,7 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro cpus: os.cpus().length, cpuModel: os.cpus()[0]?.model || 'Unknown', cpuSpeed: os.cpus()[0]?.speed || 0, - isWebDAVConfigured: !!(global.lx.config['webdav.url'] && global.lx.config['webdav.url'].trim() !== ''), + isWebDAVConfigured: !!(global.lx.config['webdav.url'] && global.lx.config['webdav.url'].trim() !== '' && global.lx.config['webdav.enable']), sourcesCount: getLoadedApisCount ? getLoadedApisCount() : 0, nodeVersion: process.version, platform: `${os.type()} ${os.arch()}`, @@ -1583,27 +1646,31 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro void userSpace.listManage.getListData().then(async data => { let albums = [] let artists = [] + let dislikeAlbums = [] + let dislikeArtists = [] try { const userDirname = getUserDirname(verifiedUser) const libraryPath = path.join(global.lx.userPath, userDirname, 'library') const albumsPath = path.join(libraryPath, 'albums.json') const artistsPath = path.join(libraryPath, 'artists.json') - - if (await fs.promises.stat(albumsPath).then(()=>true).catch(()=>false)) { - albums = JSON.parse(await fs.promises.readFile(albumsPath, 'utf8')) - } - if (await fs.promises.stat(artistsPath).then(()=>true).catch(()=>false)) { - artists = JSON.parse(await fs.promises.readFile(artistsPath, 'utf8')) - } - } catch(err) { - console.error(err) + + const dislikeLibraryPath = path.join(global.lx.userPath, userDirname, 'dislike', 'library') + const dislikeAlbumsPath = path.join(dislikeLibraryPath, 'albums.json') + const dislikeArtistsPath = path.join(dislikeLibraryPath, 'artists.json') + + if (await fs.promises.stat(albumsPath).then(() => true).catch(() => false)) albums = JSON.parse(await fs.promises.readFile(albumsPath, 'utf8')) + if (await fs.promises.stat(artistsPath).then(() => true).catch(() => false)) artists = JSON.parse(await fs.promises.readFile(artistsPath, 'utf8')) + if (await fs.promises.stat(dislikeAlbumsPath).then(() => true).catch(() => false)) dislikeAlbums = JSON.parse(await fs.promises.readFile(dislikeAlbumsPath, 'utf8')) + if (await fs.promises.stat(dislikeArtistsPath).then(() => true).catch(() => false)) dislikeArtists = JSON.parse(await fs.promises.readFile(dislikeArtistsPath, 'utf8')) + } catch (err) { + console.error(err) } res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate' }) - res.end(JSON.stringify({ ...data, albums, artists })) + res.end(JSON.stringify({ ...data, albums, artists, dislikeAlbums, dislikeArtists })) }).catch(err => { res.writeHead(500) res.end(err.message) @@ -2242,6 +2309,24 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro const added = parsed.filter((x: any) => !oldKeys.has(keyOf(x))) const removed = oldArr.filter((x: any) => !newKeys.has(keyOf(x))) fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2), 'utf-8') + + // [Mutual Exclusivity] Remove newly liked artists from the dislike list + const dislikeLibDir = path.join(global.lx.userPath, userDirname, 'dislike', 'library') + const dislikeFilePath = path.join(dislikeLibDir, 'artists.json') + if (added.length > 0 && fs.existsSync(dislikeFilePath)) { + try { + let dislikeArr: any[] = JSON.parse(fs.readFileSync(dislikeFilePath, 'utf8')) + if (Array.isArray(dislikeArr)) { + const addedKeys = new Set(added.map(keyOf)) + const newDislikeArr = dislikeArr.filter(x => !addedKeys.has(keyOf(x))) + if (newDislikeArr.length !== dislikeArr.length) { + fs.writeFileSync(dislikeFilePath, JSON.stringify(newDislikeArr, null, 2), 'utf-8') + } + } + } catch { /* ignore */ } + } + + try { const { syncNativeLibraryToSubsonic } = require('./subsonic') syncNativeLibraryToSubsonic(username, 'artists', @@ -2311,6 +2396,23 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro const added = parsed.filter((x: any) => !oldKeys.has(keyOf(x))) const removed = oldArr.filter((x: any) => !newKeys.has(keyOf(x))) fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2), 'utf-8') + + // [Mutual Exclusivity] Remove newly liked albums from the dislike list + const dislikeLibDir = path.join(global.lx.userPath, userDirname, 'dislike', 'library') + const dislikeFilePath = path.join(dislikeLibDir, 'albums.json') + if (added.length > 0 && fs.existsSync(dislikeFilePath)) { + try { + let dislikeArr: any[] = JSON.parse(fs.readFileSync(dislikeFilePath, 'utf8')) + if (Array.isArray(dislikeArr)) { + const addedKeys = new Set(added.map(keyOf)) + const newDislikeArr = dislikeArr.filter(x => !addedKeys.has(keyOf(x))) + if (newDislikeArr.length !== dislikeArr.length) { + fs.writeFileSync(dislikeFilePath, JSON.stringify(newDislikeArr, null, 2), 'utf-8') + } + } + } catch { /* ignore */ } + } + try { const { syncNativeLibraryToSubsonic } = require('./subsonic') syncNativeLibraryToSubsonic(username, 'albums', @@ -2323,6 +2425,152 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro return } + // --- DISLIKE LIBRARY ENDPOINTS --- + + // GET /api/user/dislike/library/artists + if (pathname === '/api/user/dislike/library/artists' && req.method === 'GET') { + const username = getLibUsername(req) + if (!username) { res.writeHead(401); res.end('Unauthorized'); return } + const userDirname = getUserDirname(username) + const libDir = path.join(global.lx.userPath, userDirname, 'dislike', 'library') + if (!fs.existsSync(libDir)) fs.mkdirSync(libDir, { recursive: true }) + const filePath = path.join(libDir, 'artists.json') + try { + if (!fs.existsSync(filePath)) { + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('[]'); return + } + let arr: any[] = JSON.parse(fs.readFileSync(filePath, 'utf-8')) + if (!Array.isArray(arr)) arr = [] + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(arr)) + } catch (e: any) { res.writeHead(500); res.end(e.message) } + return + } + + // POST /api/user/dislike/library/artists + if (pathname === '/api/user/dislike/library/artists' && req.method === 'POST') { + const username = getLibUsername(req) + if (!username) { res.writeHead(401); res.end('Unauthorized'); return } + void readBody(req).then(async (body) => { + try { + const parsed = JSON.parse(body) + if (!Array.isArray(parsed)) throw new Error('Expected an array') + const userDirname = getUserDirname(username) + const libDir = path.join(global.lx.userPath, userDirname, 'dislike', 'library') + if (!fs.existsSync(libDir)) fs.mkdirSync(libDir, { recursive: true }) + const filePath = path.join(libDir, 'artists.json') + + // Add dislikeRule if missing + parsed.forEach((x: any) => { + if (!x.dislikeRule) x.dislikeRule = `@${String(x.name)}` + }) + + let oldArr: any[] = [] + try { if (fs.existsSync(filePath)) oldArr = JSON.parse(fs.readFileSync(filePath, 'utf8')) } catch { /* ignore */ } + const keyOf = (x: any) => `${x.source}::${String(x.id)}` + const oldKeys = new Set(oldArr.map(keyOf)) + const newKeys = new Set(parsed.map(keyOf)) + const added = parsed.filter((x: any) => !oldKeys.has(keyOf(x))) + const removed = oldArr.filter((x: any) => !newKeys.has(keyOf(x))) + + fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2), 'utf-8') + + // [Mutual Exclusivity] Remove newly disliked artists from the liked list + const likeLibDir = path.join(global.lx.userPath, userDirname, 'library') + const likeFilePath = path.join(likeLibDir, 'artists.json') + if (added.length > 0 && fs.existsSync(likeFilePath)) { + try { + let likeArr: any[] = JSON.parse(fs.readFileSync(likeFilePath, 'utf8')) + if (Array.isArray(likeArr)) { + const addedKeys = new Set(added.map(keyOf)) + const newLikeArr = likeArr.filter(x => !addedKeys.has(keyOf(x))) + if (newLikeArr.length !== likeArr.length) { + fs.writeFileSync(likeFilePath, JSON.stringify(newLikeArr, null, 2), 'utf-8') + } + } + } catch { /* ignore */ } + } + + const { invalidateDislikeCache } = require('./utils/dislikeCache') + invalidateDislikeCache(username) + + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })) + } catch (e: any) { res.writeHead(400); res.end(e.message) } + }) + return + } + + // GET /api/user/dislike/library/albums + if (pathname === '/api/user/dislike/library/albums' && req.method === 'GET') { + const username = getLibUsername(req) + if (!username) { res.writeHead(401); res.end('Unauthorized'); return } + const userDirname = getUserDirname(username) + const libDir = path.join(global.lx.userPath, userDirname, 'dislike', 'library') + if (!fs.existsSync(libDir)) fs.mkdirSync(libDir, { recursive: true }) + const filePath = path.join(libDir, 'albums.json') + try { + if (!fs.existsSync(filePath)) { + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('[]'); return + } + let arr: any[] = JSON.parse(fs.readFileSync(filePath, 'utf-8')) + if (!Array.isArray(arr)) arr = [] + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(arr)) + } catch (e: any) { res.writeHead(500); res.end(e.message) } + return + } + + // POST /api/user/dislike/library/albums + if (pathname === '/api/user/dislike/library/albums' && req.method === 'POST') { + const username = getLibUsername(req) + if (!username) { res.writeHead(401); res.end('Unauthorized'); return } + void readBody(req).then(async (body) => { + try { + const parsed = JSON.parse(body) + if (!Array.isArray(parsed)) throw new Error('Expected an array') + const userDirname = getUserDirname(username) + const libDir = path.join(global.lx.userPath, userDirname, 'dislike', 'library') + if (!fs.existsSync(libDir)) fs.mkdirSync(libDir, { recursive: true }) + const filePath = path.join(libDir, 'albums.json') + + // Add dislikeRule if missing + parsed.forEach((x: any) => { + if (!x.dislikeRule) x.dislikeRule = `!${String(x.name)}@${String(x.artistName || '')}` + }) + + let oldArr: any[] = [] + try { if (fs.existsSync(filePath)) oldArr = JSON.parse(fs.readFileSync(filePath, 'utf8')) } catch { /* ignore */ } + const keyOf = (x: any) => `${x.source}::${String(x.id)}` + const oldKeys = new Set(oldArr.map(keyOf)) + const newKeys = new Set(parsed.map(keyOf)) + const added = parsed.filter((x: any) => !oldKeys.has(keyOf(x))) + const removed = oldArr.filter((x: any) => !newKeys.has(keyOf(x))) + + fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2), 'utf-8') + + // [Mutual Exclusivity] Remove newly disliked albums from the liked list + const likeLibDir = path.join(global.lx.userPath, userDirname, 'library') + const likeFilePath = path.join(likeLibDir, 'albums.json') + if (added.length > 0 && fs.existsSync(likeFilePath)) { + try { + let likeArr: any[] = JSON.parse(fs.readFileSync(likeFilePath, 'utf8')) + if (Array.isArray(likeArr)) { + const addedKeys = new Set(added.map(keyOf)) + const newLikeArr = likeArr.filter(x => !addedKeys.has(keyOf(x))) + if (newLikeArr.length !== likeArr.length) { + fs.writeFileSync(likeFilePath, JSON.stringify(newLikeArr, null, 2), 'utf-8') + } + } + } catch { /* ignore */ } + } + + const { invalidateDislikeCache } = require('./utils/dislikeCache') + invalidateDislikeCache(username) + + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })) + } catch (e: any) { res.writeHead(400); res.end(e.message) } + }) + return + } + // [新增] Get User Settings (User Auth) if (pathname === '/api/user/settings' && req.method === 'GET') { const reqUsername = req.headers['x-user-name'] as string @@ -2717,7 +2965,7 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro try { const body = JSON.parse(await readBody(req)) const explicitIsCustomDir = body?.isCustomDir === undefined ? undefined : Boolean(body?.isCustomDir) - + if (explicitIsCustomDir) { const userCfg = getUserConfig(username) if (!userCfg?.allowOperateCustomMusicDir) { @@ -4548,7 +4796,7 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro const rangeHeader = req.headers['range'] const isFullRange = rangeHeader === 'bytes=0-' - if (isTaggingMode && (!rangeHeader || isFullRange)) { + if (isTaggingMode && (!rangeHeader || isFullRange)) { const songName = urlObj.searchParams.get('name') || '' const artist = urlObj.searchParams.get('singer') || '' const album = urlObj.searchParams.get('album') || '' @@ -5648,6 +5896,139 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro return } + // [新增] dislike 规则 API + // 与 Subsonic 评分联动共用同一份 lx-music 原生规则,保证各端一致: + // GET /api/music/dislike 返回已解析的规则集(歌曲 / 歌手 / 专辑) + // POST /api/music/dislike/add body: { type, name?, singer?, source?, albumId? } + // POST /api/music/dislike/remove body: 同上 + if (pathname === '/api/music/dislike' && req.method === 'GET') { + const verified = verifyUserAuth(req) + if (!verified) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void getUserSpace(verified).dislikeManage.getDislikeRules().then(rules => { + const dm = getUserSpace(verified).dislikeManage + const rulesString = dm.dislikeDataManage.getDislikeRulesString() + const data: any = serializeDislikeRules(rulesString, verified) + data.dislikeList = dm.dislikeDataManage.dislikeRules.dislikeList || [] + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify({ success: true, data, options: dislikeMatchOptions() })) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + if ((pathname === '/api/music/dislike/add' || pathname === '/api/music/dislike/remove') && req.method === 'POST') { + void readBody(req).then(async body => { + const verified = verifyUserAuth(req) + if (!verified) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + try { + const isAdd = pathname === '/api/music/dislike/add' + const payload = JSON.parse(body || '{}') + const type = String(payload.type || 'song') + const name = String(payload.name || '') + const singer = String(payload.singer || '') + const source = String(payload.source || '') + const id = String(payload.id || '') + const albumId = String(payload.albumId || '') + const pic = String(payload.pic || '') + const interval = String(payload.interval || '') + const meta = payload.meta || {} + + const dm = getUserSpace(verified).dislikeManage + + let removeKeys: Set = new Set() + if (type === 'album') { + const albumName = String(payload.albumName || '') + if (!albumName) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Missing albumName' })) + return + } + const singers = splitSingers(singer) + if (isAdd) await dm.dislikeDataManage.addDislikeAlbums(singers.map(s => ({ albumName, singer: s }))) + else for (const s of singers) { + removeKeys.add(encodeAlbumRule(normalizeText(albumName), s)) + removeKeys.add(normalizeText(encodeAlbumRule(albumName, s))) + } + } else if (type === 'singer') { + if (!singer) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Missing singer' })) + return + } + if (isAdd) await dm.dislikeDataManage.addDislikeInfo([{ name: '', singer, dislikeRule: '' }]) + else removeKeys.add(`@${normalizeText(singer)}`) + } else { + if (!name) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Missing name' })) + return + } + if (isAdd) { + const { type, ...songData } = payload + songData.dislikeRule = '' + await dm.dislikeDataManage.addDislikeInfo([songData]) + } else { + if (singer) { + removeKeys.add(`${name}@${singer}`.toLowerCase()) + removeKeys.add(`${normalizeText(name)}@${normalizeText(singer)}`.toLowerCase()) + } + removeKeys.add(name.toLowerCase()) + removeKeys.add(normalizeText(name).toLowerCase()) + } + } + + const dislikeRatingThreshold = global.lx.config['subsonic.dislikeRating'] ?? 1 + if (global.lx.config['subsonic.linkDislikeToRating'] && dislikeRatingThreshold > 0 && id && source) { + const subId = id.startsWith(`${source}_`) ? id : `${source}_${id}` + try { + const { syncDislikeToRating } = require('./subsonic') + await syncDislikeToRating(verified, subId, isAdd ? 1 : 0) + } catch (e) { + console.error('[Dislike API] 评分回写失败:', e) + } + } + + if (!isAdd && removeKeys.size > 0) { + const rulesString = dm.dislikeDataManage.getDislikeRulesString() + const lines = rulesString.split('\n').filter((l: string) => l.trim()) + const remain = lines.filter((l: string) => { + const trimmed = l.trim().toLowerCase() + if (removeKeys.has(trimmed)) return false + // Also check without spaces or with alias + return true + }) + if (remain.length !== lines.length) { + await dm.dislikeDataManage.overwirteDislikeInfo(remain.join('\n')) + } + } + + await dm.createSnapshot() + invalidateDislikeCache(verified) + const rules = await dm.getDislikeRules() + const finalRulesString = dm.dislikeDataManage.getDislikeRulesString() + const data: any = serializeDislikeRules(finalRulesString, verified) + data.dislikeList = dm.dislikeDataManage.dislikeRules.dislikeList || [] + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify({ success: true, data, options: dislikeMatchOptions() })) + } catch (err: any) { + console.error('[Dislike API] Error:', err?.message) + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + } + }) + return + } + // [新增] 封面 API (备用) // [新增] 自定义源管理 API @@ -5933,9 +6314,24 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro 'subsonic.onlineSearchSources': global.lx.config['subsonic.onlineSearchSources'] ?? 'wy,tx,kw,kg,mg', 'subsonic.publicLeaderboards': global.lx.config['subsonic.publicLeaderboards'] ?? false, 'subsonic.leaderboardSource': global.lx.config['subsonic.leaderboardSource'] ?? 'tx', + 'subsonic.dislikeRating': global.lx.config['subsonic.dislikeRating'] ?? 1, + 'subsonic.linkRatingToDislike': global.lx.config['subsonic.linkRatingToDislike'] ?? false, + 'subsonic.linkDislikeToRating': global.lx.config['subsonic.linkDislikeToRating'] ?? false, + 'subsonic.hideDisliked': global.lx.config['subsonic.hideDisliked'] ?? true, + 'subsonic.dislikeCrossSource': global.lx.config['subsonic.dislikeCrossSource'] ?? false, + 'subsonic.dislikeNoRecommend': global.lx.config['subsonic.dislikeNoRecommend'] ?? true, + 'subsonic.dislikeDuetMode': global.lx.config['subsonic.dislikeDuetMode'] ?? 'any', + 'subsonic.dislikeNormalizeName': global.lx.config['subsonic.dislikeNormalizeName'] ?? true, + 'subsonic.dislikeRequireSinger': global.lx.config['subsonic.dislikeRequireSinger'] ?? true, 'subsonic.lyricTranslation': global.lx.config['subsonic.lyricTranslation'] ?? true, 'subsonic.cacheOnPlay': global.lx.config['subsonic.cacheOnPlay'] ?? false, 'subsonic.playCacheFirst': global.lx.config['subsonic.playCacheFirst'] ?? true, + 'subsonic.quality.enabled': global.lx.config['subsonic.quality.enabled'] ?? true, + 'subsonic.quality.priority': global.lx.config['subsonic.quality.priority'] ?? 'flac,320k,128k', + 'subsonic.quality.clientCapMode': global.lx.config['subsonic.quality.clientCapMode'] ?? 'soft', + 'subsonic.source.priority': global.lx.config['subsonic.source.priority'] ?? 'kw,tx,wy,mg,kg', + 'subsonic.source.crossPlatform': global.lx.config['subsonic.source.crossPlatform'] ?? true, + 'subsonic.source.autoSwitchCustom': global.lx.config['subsonic.source.autoSwitchCustom'] ?? true, 'singer.sourcePriority': (global.lx.config['singer.sourcePriority'] || ['tx', 'wy']).join(','), 'artist.maxFetchPages': global.lx.config['artist.maxFetchPages'] ?? 20, 'system.allowUnsafeVM': global.lx.config['system.allowUnsafeVM'] || false, @@ -6052,9 +6448,28 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro const s = String(newConfig['subsonic.leaderboardSource']).trim().toLowerCase() if (['tx', 'wy', 'kg', 'kw', 'mg'].includes(s)) global.lx.config['subsonic.leaderboardSource'] = s } + if (newConfig['subsonic.dislikeRating'] !== undefined) global.lx.config['subsonic.dislikeRating'] = Number(newConfig['subsonic.dislikeRating']) + if (newConfig['subsonic.hideDisliked'] !== undefined) global.lx.config['subsonic.hideDisliked'] = !!newConfig['subsonic.hideDisliked'] + if (newConfig['subsonic.dislikeCrossSource'] !== undefined) global.lx.config['subsonic.dislikeCrossSource'] = !!newConfig['subsonic.dislikeCrossSource'] + if (newConfig['subsonic.dislikeNoRecommend'] !== undefined) global.lx.config['subsonic.dislikeNoRecommend'] = !!newConfig['subsonic.dislikeNoRecommend'] + if (newConfig['subsonic.dislikeDuetMode'] !== undefined) { + const dm = String(newConfig['subsonic.dislikeDuetMode']).trim().toLowerCase() + if (['any', 'all', 'primary'].includes(dm)) global.lx.config['subsonic.dislikeDuetMode'] = dm as any + } + if (newConfig['subsonic.dislikeNormalizeName'] !== undefined) global.lx.config['subsonic.dislikeNormalizeName'] = !!newConfig['subsonic.dislikeNormalizeName'] + if (newConfig['subsonic.dislikeRequireSinger'] !== undefined) global.lx.config['subsonic.dislikeRequireSinger'] = !!newConfig['subsonic.dislikeRequireSinger'] + if (newConfig['subsonic.linkRatingToDislike'] !== undefined) global.lx.config['subsonic.linkRatingToDislike'] = !!newConfig['subsonic.linkRatingToDislike'] + if (newConfig['subsonic.linkDislikeToRating'] !== undefined) global.lx.config['subsonic.linkDislikeToRating'] = !!newConfig['subsonic.linkDislikeToRating'] + if (newConfig['subsonic.recommendPoolSize'] !== undefined) global.lx.config['subsonic.recommendPoolSize'] = Number(newConfig['subsonic.recommendPoolSize']) if (newConfig['subsonic.lyricTranslation'] !== undefined) global.lx.config['subsonic.lyricTranslation'] = newConfig['subsonic.lyricTranslation'] if (newConfig['subsonic.cacheOnPlay'] !== undefined) global.lx.config['subsonic.cacheOnPlay'] = newConfig['subsonic.cacheOnPlay'] if (newConfig['subsonic.playCacheFirst'] !== undefined) global.lx.config['subsonic.playCacheFirst'] = newConfig['subsonic.playCacheFirst'] + if (newConfig['subsonic.quality.enabled'] !== undefined) global.lx.config['subsonic.quality.enabled'] = !!newConfig['subsonic.quality.enabled'] + if (newConfig['subsonic.quality.priority'] !== undefined) global.lx.config['subsonic.quality.priority'] = String(newConfig['subsonic.quality.priority']) + if (newConfig['subsonic.quality.clientCapMode'] !== undefined && ['hard', 'soft'].includes(newConfig['subsonic.quality.clientCapMode'])) global.lx.config['subsonic.quality.clientCapMode'] = newConfig['subsonic.quality.clientCapMode'] + if (newConfig['subsonic.source.priority'] !== undefined) global.lx.config['subsonic.source.priority'] = String(newConfig['subsonic.source.priority']) + if (newConfig['subsonic.source.crossPlatform'] !== undefined) global.lx.config['subsonic.source.crossPlatform'] = !!newConfig['subsonic.source.crossPlatform'] + if (newConfig['subsonic.source.autoSwitchCustom'] !== undefined) global.lx.config['subsonic.source.autoSwitchCustom'] = !!newConfig['subsonic.source.autoSwitchCustom'] if (newConfig['singer.sourcePriority'] !== undefined) { const priority = String(newConfig['singer.sourcePriority']).split(',').filter(s => s === 'tx' || s === 'wy') as Array<'tx' | 'wy'> if (priority.length > 0) global.lx.config['singer.sourcePriority'] = priority @@ -6125,9 +6540,25 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro 'subsonic.onlineSearchSources': global.lx.config['subsonic.onlineSearchSources'], 'subsonic.publicLeaderboards': global.lx.config['subsonic.publicLeaderboards'], 'subsonic.leaderboardSource': global.lx.config['subsonic.leaderboardSource'], + 'subsonic.dislikeRating': global.lx.config['subsonic.dislikeRating'], + 'subsonic.linkRatingToDislike': global.lx.config['subsonic.linkRatingToDislike'], + 'subsonic.linkDislikeToRating': global.lx.config['subsonic.linkDislikeToRating'], + 'subsonic.hideDisliked': global.lx.config['subsonic.hideDisliked'], + 'subsonic.dislikeCrossSource': global.lx.config['subsonic.dislikeCrossSource'], + 'subsonic.dislikeNoRecommend': global.lx.config['subsonic.dislikeNoRecommend'], + 'subsonic.dislikeDuetMode': global.lx.config['subsonic.dislikeDuetMode'], + 'subsonic.dislikeNormalizeName': global.lx.config['subsonic.dislikeNormalizeName'], + 'subsonic.dislikeRequireSinger': global.lx.config['subsonic.dislikeRequireSinger'], + 'subsonic.recommendPoolSize': global.lx.config['subsonic.recommendPoolSize'], 'subsonic.lyricTranslation': global.lx.config['subsonic.lyricTranslation'], 'subsonic.cacheOnPlay': global.lx.config['subsonic.cacheOnPlay'], 'subsonic.playCacheFirst': global.lx.config['subsonic.playCacheFirst'], + 'subsonic.quality.enabled': global.lx.config['subsonic.quality.enabled'], + 'subsonic.quality.priority': global.lx.config['subsonic.quality.priority'], + 'subsonic.quality.clientCapMode': global.lx.config['subsonic.quality.clientCapMode'], + 'subsonic.source.priority': global.lx.config['subsonic.source.priority'], + 'subsonic.source.crossPlatform': global.lx.config['subsonic.source.crossPlatform'], + 'subsonic.source.autoSwitchCustom': global.lx.config['subsonic.source.autoSwitchCustom'], 'singer.sourcePriority': global.lx.config['singer.sourcePriority'], 'artist.maxFetchPages': global.lx.config['artist.maxFetchPages'], 'cache.namingPattern': global.lx.config['cache.namingPattern'], @@ -6471,7 +6902,7 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro let payload: any = {} try { payload = JSON.parse(body || '{}') - } catch (e) {} + } catch (e) { } const mode = payload.mode || 'auto' const targetFilename = payload.targetFilename @@ -7284,12 +7715,14 @@ export const startServer = async (port: number, ip: string) => { } }) - await handleStartServer(port, ip).then(() => { + await handleStartServer(port, ip).then(async () => { // console.log('sync server started') status.status = true status.message = '' status.address = ip == '0.0.0.0' ? getAddress() : [ip] + + // void generateCode() // codeTools.start() }).catch(err => { diff --git a/src/server/subsonic.ts b/src/server/subsonic.ts index 98d713d6..4bac303c 100644 --- a/src/server/subsonic.ts +++ b/src/server/subsonic.ts @@ -6,7 +6,13 @@ import { callUserApiGetMusicUrl } from '@/server/userApi' import { downloadAndCache, checkCache, serveCacheFile } from '@/server/fileCache' import { getSingerPic, getSingerDetail, getSingerMid } from '@/server/utils/singer' import { fetchRecommendedAlbums } from '@/server/utils/recommendAlbums' +import { fetchRecommendedSongs } from '@/server/utils/recommendSongs' +import { filterDisliked, splitSingers, type DislikeRuleSet } from '@/modules/dislike/match' +import { encodeAlbumRule } from '@/modules/dislike/utils' +import { normalizeText } from '@/server/utils/songVersion' +import { getCachedDislikeRuleSet, invalidateDislikeCache } from '@/server/utils/dislikeCache' import { proxyCoverImage } from '@/server/coverProxy' +import { subsonicLog } from '@/utils/log4js' import { fetchGenres, fetchRadios, fetchPlaylistsByGenre, fetchRadioSongs, fetchPlaylistSongs, fetchSongsByGenre } from '@/server/utils/discovery' import fs from 'fs' import path from 'path' @@ -40,7 +46,7 @@ async function cachedRecommend(type: string, size: number): Promise { } // 抓取失败:若有上次成功结果(即便已过期),回退返回,避免每日推荐空白 if (cached && cached.data.length > 0) { - console.warn(`[Subsonic] recommend(${type}) 刷新失败,回退到上次缓存的 ${cached.data.length} 条`) + subsonicLog.warn(`[Subsonic] recommend(${type}) 刷新失败,回退到上次缓存的 ${cached.data.length} 条`) return cached.data } return [] @@ -104,7 +110,7 @@ export function readSubsonicMeta(username: string): SubsonicMeta { } } } catch (e) { - console.error('[Subsonic] Failed to read subsonic-meta.json:', e) + subsonicLog.error('[Subsonic] Failed to read subsonic-meta.json:', e) } return { starredAlbums: [], starredArtists: [], starredArtistNames: [], ratings: {} } } @@ -116,7 +122,7 @@ export function writeSubsonicMeta(username: string, meta: SubsonicMeta) { if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true }) fs.writeFileSync(filePath, JSON.stringify(meta), 'utf8') } catch (e) { - console.error('[Subsonic] Failed to write subsonic-meta.json:', e) + subsonicLog.error('[Subsonic] Failed to write subsonic-meta.json:', e) } } @@ -151,7 +157,62 @@ export function syncNativeLibraryToSubsonic( } meta.starredArtistNames = Array.from(artistNames) writeSubsonicMeta(username, meta) - console.log(`[Subsonic] 原生收藏(${type}) -> 星标 同步完成: +${added.length} / -${removed.length} (user=${username})`) + subsonicLog.debug(`[Subsonic] 原生收藏(${type}) -> 星标 同步完成: +${added.length} / -${removed.length} (user=${username})`) +} + +/** + * 同一用户 subsonic-meta.json 的「读-改-写」串行化锁。 + * + * handleSetRating / handleStar / syncDislikeToRating 三处都会对同一个文件做读-改-写且互不互斥, + * 而中间存在 await 会让出事件循环;Subsonic 客户端批量操作时容易并发发出 star 与 setRating, + * 后写入者会整体覆盖前者的修改,导致用户刚设置的星标或评分丢失。 + * 这里按 username 串成 Promise 链,保证同一用户的读-改-写严格顺序执行。 + */ +const metaLocks = new Map>() + +/** + * 同一用户 subsonic-meta.json 的「读-改-写」串行化锁。 + * 避免 handleSetRating / handleStar / syncDislikeToRating 并发写同一文件互相覆盖。 + */ +function withMetaLock(username: string, task: () => Promise): Promise { + const prev = metaLocks.get(username) ?? Promise.resolve() + const run = (): Promise => task() + // 前一个任务无论成功还是失败都要继续,避免一次异常卡死整条队列 + const next = prev.then(run, run) + const tail = next.then(() => undefined, () => undefined) + metaLocks.set(username, tail) + tail.then(() => { + if (metaLocks.get(username) === tail) metaLocks.delete(username) + }) + return next +} + +/** + * 双向联动:网页端「不喜欢」(dislike) <-> Subsonic 评分(rating)。 + * 网页给某首歌点「不喜欢」时,回写该用户 subsonic-meta.json 的 ratings 映射, + * 使 Subsonic 客户端能看到这颗低星(与正向联动 handleSetRating 共用同一份存储): + * rating > 0 -> ratings[id] = rating(低星,落入 dislike 区间) + * rating <= 0 -> 删除 ratings[id](恢复未评) + * id 即 Subsonic 歌曲 id(source_songId)。模块级函数,供 server.ts 的 /api/music/dislike 直接调用。 + */ +export async function syncDislikeToRating(username: string, id: string, rating: number): Promise { + if (!id) return + try { + await withMetaLock(username, async () => { + const meta = readSubsonicMeta(username) + const r = Math.max(0, Math.min(5, Math.floor(rating))) + if (r <= 0) { + if (meta.ratings[id] === undefined) return + delete meta.ratings[id] + } else { + meta.ratings[id] = r + } + writeSubsonicMeta(username, meta) + subsonicLog.debug(`[Subsonic] dislike<->rating 同步: ${id} -> ${r} (user=${username})`) + }) + } catch (e) { + subsonicLog.error('[Subsonic] dislike<->rating 同步失败:', e) + } } /** @@ -193,6 +254,9 @@ class SubsonicHandler { // 当前用户 love 列表歌曲 id 集合缓存,用于歌曲序列化时标记 starred(按用户名隔离,避免并发串号) private loveIdSets = new Map>() + // [修复] 每请求缓存当前用户的评星 map(id -> 1~5),供歌曲序列化返回 userRating, + // 否则客户端重载后看不到星标。与 loveIdSets 同源,避免每首歌读盘。 + private userRatingsCache = new Map>() private currentUsername = '' private getOnlineSongCachePath(): string { @@ -213,7 +277,7 @@ class SubsonicHandler { fs.appendFileSync(this.getSourceErrorLogPath(), block) } catch { /* 写错误日志失败不阻塞主流程 */ } // 控制台精简显示 - console.warn(`[Subsonic] 音源错误 ${tag}: ${detail} (详见 subsonic-source-errors.log)`) + subsonicLog.warn(`[Subsonic] 音源错误 ${tag}: ${detail} (详见 subsonic-source-errors.log)`) } private loadOnlineSongCache() { @@ -225,11 +289,11 @@ class SubsonicHandler { const arr = JSON.parse(fs.readFileSync(p, 'utf8')) if (Array.isArray(arr)) { for (const m of arr) if (m && m.id) this.onlineSongCache.set(m.id, m) - console.log(`[Subsonic] onlineSongCache 已从磁盘加载 ${this.onlineSongCache.size} 条`) + subsonicLog.debug(`[Subsonic] onlineSongCache 已从磁盘加载 ${this.onlineSongCache.size} 条`) } } } catch (e) { - console.error('[Subsonic] 加载 onlineSongCache 失败:', e) + subsonicLog.error('[Subsonic] 加载 onlineSongCache 失败:', e) } } @@ -250,7 +314,7 @@ class SubsonicHandler { fs.writeFileSync(tmp, JSON.stringify(arr), 'utf8') fs.renameSync(tmp, p) } catch (e) { - console.error('[Subsonic] 保存 onlineSongCache 失败:', e) + subsonicLog.error('[Subsonic] 保存 onlineSongCache 失败:', e) } }, 1000) } @@ -434,7 +498,7 @@ class SubsonicHandler { } params = mergedParams } catch (e) { - console.error('[Subsonic] POST body parse error:', e) + subsonicLog.error('[Subsonic] POST body parse error:', e) } } @@ -455,7 +519,14 @@ class SubsonicHandler { const listData = await getUserSpace(username).listManage.getListData() this.loveIdSets.set(username, new Set((listData.loveList || []).map((m: any) => m.id))) } catch (e) { - console.error('[Subsonic] 计算 love 列表失败:', e) + subsonicLog.error('[Subsonic] 计算 love 列表失败:', e) + } + // [修复] 预读用户评星 map,供歌曲序列化返回 userRating(客户端星标显示依赖它) + try { + const meta = await this.getUserSubsonicMeta(username) + this.userRatingsCache.set(username, meta.ratings || {}) + } catch (e) { + subsonicLog.error('[Subsonic] 读取用户评星失败:', e) } } @@ -470,7 +541,15 @@ class SubsonicHandler { if (logTitle) logDetails += ` title="${logTitle}"` if (global.lx.config['subsonic.enableDebug']) { - console.log(`[Subsonic Debug] ${req.method} /${method} (${format}) ${logDetails}`) + subsonicLog.debug(`[Subsonic Debug] ${req.method} /${method} (${format}) ${logDetails}`) + // [实测] 完整记录客户端发来的所有参数,用于确认真实请求到底带了哪些字段(密码脱敏) + const rawParams: Record = {} + // 脱敏:明文密码(p / password) 与令牌认证(t=md5(密码+salt) / s=salt) 均不得落入日志 + const SENSITIVE_KEYS = new Set(['p', 'password', 't', 's']) + params.forEach((v, k) => { + rawParams[k] = SENSITIVE_KEYS.has(k) ? '***' : v + }) + subsonicLog.debug(`[Subsonic RAW] /${method} ${JSON.stringify(rawParams)}`) } try { @@ -558,6 +637,9 @@ class SubsonicHandler { case 'unstar': return this.handleStar(res, username, params, format, false) + case 'setRating': + return this.handleSetRating(res, username, params, format) + case 'getRandomSongs': case 'getSongsByGenre': case 'getSongsByGenre2': @@ -570,6 +652,11 @@ class SubsonicHandler { case 'getTopSongs': return this.handleGetTopSongs(res, username, params, format) + case 'getRecommendedSongs': + case 'getDailySongs': + case 'getSongsByTag': + return this.handleGetRecommendedSongs(res, username, params, format) + case 'updatePlaylist': return this.handleUpdatePlaylist(res, username, params, format) @@ -592,12 +679,12 @@ class SubsonicHandler { default: if (global.lx.config['subsonic.enableDebug']) { - console.warn(`[Subsonic Debug ⚠️ 未实现的接口] ${req.method} /${method} (${format}) ${logDetails}`) + subsonicLog.warn(`[Subsonic Debug ⚠️ 未实现的接口] ${req.method} /${method} (${format}) ${logDetails}`) } return this.sendError(res, 0, 'Method not found: ' + method, format) } } catch (err: any) { - console.error('[Subsonic] Error:', err) + subsonicLog.error('[Subsonic] Error:', err) return this.sendError(res, 0, err.message || 'Internal server error', format) } } @@ -614,7 +701,7 @@ class SubsonicHandler { const content = await fs.promises.readFile(libPath, 'utf8') return JSON.parse(content) } catch (e) { - console.error(`[Subsonic] Error reading library ${type}:`, e) + subsonicLog.error(`[Subsonic] Error reading library ${type}:`, e) return [] } } @@ -678,6 +765,11 @@ class SubsonicHandler { const singer = music.singer || 'Unknown Artist' const source = music.source + // [缓存] 每首对外服务的歌曲都落入持久化 onlineSongCache(data/subsonic-online-cache.json,重启不丢)。 + // 这样客户端在「未播放的顶框」直接就歌曲评星时,服务端仅凭 setRating 的 id 也能离线还原出歌名/歌手, + // 写进 dislike 规则,无需再走网络在线回源(本地 GFW 下会失败)。 + this.cacheOnlineSong(music) + // [优化] 深度提取专辑信息:兼容 SDK 原始对象结构 const albumName = meta.albumName || (music as any).albumName || (music as any).album?.name || 'Unknown Album' // 针对 tx 平台优先使用 albumMid (00...) 构造 alb_ ID,因为封面构造依赖它 @@ -710,6 +802,8 @@ class SubsonicHandler { const finalArtistId = artistIdOverride || defaultArtistId const starred = (username && this.loveIdSets.get(username)?.has(id)) ? new Date().toISOString() : undefined + // [修复] 返回用户评星,客户端星标显示依赖 userRating 字段(0 表示未评) + const userRating = username ? (this.userRatingsCache.get(username)?.[id] ?? 0) : 0 return { id, @@ -727,6 +821,7 @@ class SubsonicHandler { duration: this.parseDuration(music.interval), ...this.getBestQualityMeta(music), ...(starred ? { starred } : {}), + userRating, isVideo: false, isDir: false, // 某些客户端 (如 Feishin) 在特定视图下不喜欢非标准字段,可以保留但确保标准字段优先 @@ -899,7 +994,7 @@ class SubsonicHandler { private async handleGetPlaylists(res: http.ServerResponse, username: string, format: string) { const userSpace = getUserSpace(username) const listData = await userSpace.listManage.getListData() - // console.log(`[Subsonic] handleGetPlaylists for ${username}: default=${listData.defaultList.length}, love=${listData.loveList.length}, userLists=${listData.userList.length}`) + // subsonicLog.debug(`[Subsonic] handleGetPlaylists for ${username}: default=${listData.defaultList.length}, love=${listData.loveList.length}, userLists=${listData.userList.length}`) const buildPlaylist = (id: string, name: string, musics: any[], created?: string, coverArt?: string) => ({ id, @@ -921,10 +1016,13 @@ class SubsonicHandler { const coverArt = (musics[0] as any)?.meta?.picUrl || (musics[0] as any)?.img || 'logo' playlists.push(buildPlaylist('default', '默认列表', musics, undefined, coverArt)) } + // 注意:love 列表在 getPlaylists 中与「歌单(用户自建播放列表)」平级展示。 + // 为避免与前端侧边栏折叠面板标题「我的收藏」(见 public/music/index.html) 重名, + // 此处 love 列表命名为「我的喜爱」,与面板内部 ♥ 列表显示名称保持一致。 { const musics = listData.loveList const coverArt = (musics[0] as any)?.meta?.picUrl || (musics[0] as any)?.img || 'logo' - playlists.push(buildPlaylist('love', '我的收藏', musics, undefined, coverArt)) + playlists.push(buildPlaylist('love', '我的喜爱', musics, undefined, coverArt)) } for (const list of listData.userList) { @@ -946,7 +1044,7 @@ class SubsonicHandler { const lbPlaylists = await this.getLeaderboardPlaylists() playlists.push(...lbPlaylists) } catch (err) { - console.error('[Subsonic] Append leaderboard playlists failed:', err) + subsonicLog.error('[Subsonic] Append leaderboard playlists failed:', err) } } @@ -991,6 +1089,11 @@ class SubsonicHandler { } } + // [dislike] 命中不喜欢规则的歌曲从歌单中剔除 + if (global.lx.config['subsonic.hideDisliked']) { + musics = await this.filterDislikedSongs(username, musics) + } + const playlistMeta = { id, name: listName, @@ -1093,7 +1196,7 @@ class SubsonicHandler { if (changed) await userSpace.listManage.createSnapshot() return this.sendResponse(res, {}, format) } catch (err: any) { - console.error('[Subsonic] updatePlaylist error:', err) + subsonicLog.error('[Subsonic] updatePlaylist error:', err) return this.sendError(res, 0, err.message || 'Failed to update playlist', format) } } @@ -1123,7 +1226,7 @@ class SubsonicHandler { return this.handleGetPlaylist(res, username, new URLSearchParams({ id: playlistId }), format) } catch (err: any) { - console.error('[Subsonic] createPlaylist error:', err) + subsonicLog.error('[Subsonic] createPlaylist error:', err) return this.sendError(res, 0, err.message || 'Failed to create playlist', format) } } @@ -1146,7 +1249,7 @@ class SubsonicHandler { await userSpace.listManage.createSnapshot() return this.sendResponse(res, {}, format) } catch (err: any) { - console.error('[Subsonic] deletePlaylist error:', err) + subsonicLog.error('[Subsonic] deletePlaylist error:', err) return this.sendError(res, 0, err.message || 'Failed to delete playlist', format) } } @@ -1187,7 +1290,7 @@ class SubsonicHandler { } }) } catch (err) { - console.error('[Subsonic] getLeaderboardPlaylists error:', err) + subsonicLog.error('[Subsonic] getLeaderboardPlaylists error:', err) return [] } } @@ -1206,13 +1309,19 @@ class SubsonicHandler { const listName = board ? `榜单·${board.name}` : '排行榜' const data = await lb.getList(bangid, 1) - const musics = (data?.list || []).map((s: any) => { + let musics: LX.Music.MusicInfo[] = (data?.list || []).map((s: any) => { const m = this.normalizeLeaderboardSong(s, source) this.cacheOnlineSong(m) return m }) + // [dislike] 排行榜同理剔除不喜欢的歌曲 + if (global.lx.config['subsonic.hideDisliked']) { + musics = await this.filterDislikedSongs(username, musics) + } + const coverArt = (musics[0] as any)?.img || 'logo' + const playlistMeta = { id, name: listName, @@ -1243,7 +1352,7 @@ class SubsonicHandler { }, }, format) } catch (err: any) { - console.error('[Subsonic] handleGetLeaderboardPlaylist error:', err) + subsonicLog.error('[Subsonic] handleGetLeaderboardPlaylist error:', err) return this.sendError(res, 0, '获取排行榜失败: ' + (err?.message || err), format) } } @@ -1299,6 +1408,27 @@ class SubsonicHandler { albumId: s.albumMid || album.id, }, } as any)) + + // [修复] 本地专辑可能只存了名称/封面而没有 list(如 Subsonic 星标回写), + // 表现就是专辑 0 首。这里用 albumId 向音源补全整张专辑。 + if (musics.length === 0) { + const albSource = album.source || 'wy' + const albMid = String(album.meta?.albumId || album.id || '') + if (albMid && musicSdk[albSource]?.extendDetail?.getAlbumSongs) { + try { + const data = await musicSdk[albSource].extendDetail.getAlbumSongs(albMid) + musics = (data.list || []).map((s: any) => ({ + ...s, + id: `${albSource}_${s.songmid || s.songId}`, + source: albSource, + })) as any + if (data.name) listName = data.name + albumPublishTime = data.publishTime || album.publishTime + } catch (e: any) { + subsonicLog.error(`[Subsonic] 本地专辑补全失败 (${albMid}):`, e?.message) + } + } + } } /* } else if (id.startsWith('alb_hot_')) { @@ -1329,7 +1459,7 @@ class SubsonicHandler { })) listName = '热门歌曲' } catch (e) { - console.error(`[Subsonic] SDK getArtistSongs (for virtual album) error:`, e) + subsonicLog.error(`[Subsonic] SDK getArtistSongs (for virtual album) error:`, e) } } */ @@ -1354,7 +1484,7 @@ class SubsonicHandler { } } as any)) } catch (e) { - console.error(`[Subsonic] Fetch radio songs failed:`, e) + subsonicLog.error(`[Subsonic] Fetch radio songs failed:`, e) } } else if (id.startsWith('alb_tx_playlist_')) { // [新增] 处理虚拟出的歌单详情 @@ -1364,19 +1494,19 @@ class SubsonicHandler { listName = result.name musics = result.list as any } catch (e) { - console.error(`[Subsonic] Fetch playlist detail failed:`, e) + subsonicLog.error(`[Subsonic] Fetch playlist detail failed:`, e) } } else if (id.startsWith('alb_')) { // [新增] 处理来自 SDK 的专辑详情 const parts = id.split('_') const source = parts[1] const realId = parts.slice(2).join('_') - // console.log(`[Subsonic] getAlbum SDK Route: source=${source}, realId=${realId}`) + // subsonicLog.debug(`[Subsonic] getAlbum SDK Route: source=${source}, realId=${realId}`) if (musicSdk[source]?.extendDetail?.getAlbumSongs) { try { const data = await musicSdk[source].extendDetail.getAlbumSongs(realId) - // console.log(`[Subsonic] getAlbum SDK Response: name=${data?.name}, songCount=${data?.list?.length}`) + // subsonicLog.debug(`[Subsonic] getAlbum SDK Response: name=${data?.name}, songCount=${data?.list?.length}`) musics = (data.list || []).map((s: any) => ({ ...s, id: `${source}_${s.songmid || s.songId}`, @@ -1386,10 +1516,10 @@ class SubsonicHandler { listName = data.name || (musics[0] as any)?.albumName || (musics[0] as any)?.meta?.albumName || 'Album Detail' albumPublishTime = data.publishTime } catch (e: any) { - console.error(`[Subsonic] SDK getAlbumSongs error for ${id}:`, e?.message) + subsonicLog.error(`[Subsonic] SDK getAlbumSongs error for ${id}:`, e?.message) } } else { - console.warn(`[Subsonic] SDK missing extendDetail.getAlbumSongs for ${source}`) + subsonicLog.warn(`[Subsonic] SDK missing extendDetail.getAlbumSongs for ${source}`) } } else if (id.startsWith('album_')) { // 聚合专辑 ID(由 getAlbumList/getAlbumList2 生成) @@ -1412,6 +1542,31 @@ class SubsonicHandler { if (musics.length > 0) { listName = (musics[0] as any).meta?.albumName || musics[0].name } + + // [修复] 聚合专辑原本只含用户收藏的那几首,表现就是"每张专辑只有 1-2 首"。 + // 这里用收藏曲目里的 albumMid 向音源补全整张专辑; + // 收藏标记由 musicToSong* 依据 loveIdSets 自动补上,因此无需保留本地优先。 + const seed = musics.find(m => (m as any).meta?.albumId || (m as any).albumMid || (m as any).album?.mid) + const albumMid = seed ? String((seed as any).meta?.albumId || (seed as any).albumMid || (seed as any).album?.mid) : '' + const albumSource = seed?.source + if (albumMid && albumSource && musicSdk[albumSource]?.extendDetail?.getAlbumSongs) { + try { + const data = await musicSdk[albumSource].extendDetail.getAlbumSongs(albumMid) + const fullList = (data.list || []).map((s: any) => ({ + ...s, + id: `${albumSource}_${s.songmid || s.songId}`, + source: albumSource, + })) + // 仅当在线结果更完整时才替换,避免接口异常时反而丢数据 + if (fullList.length > musics.length) { + musics = fullList as any + if (data.name) listName = data.name + albumPublishTime = data.publishTime || albumPublishTime + } + } catch (e: any) { + subsonicLog.error(`[Subsonic] 聚合专辑补全失败 (${id}):`, e?.message) + } + } } else if (id.includes('_')) { // 动态支持:如果客户端把某首歌的 id 当作专辑 id 来查 const found = await this.findMusicById(username, id) @@ -1436,6 +1591,11 @@ class SubsonicHandler { } } + // [dislike] 命中不喜欢规则的歌曲从专辑曲目中剔除 + if (global.lx.config['subsonic.hideDisliked']) { + musics = await this.filterDislikedSongs(username, musics) + } + const albumMeta = { id, name: listName, @@ -1590,7 +1750,7 @@ class SubsonicHandler { } } as any)) } catch (e) { - console.error(`[Subsonic] Fetch radio songs failed:`, e) + subsonicLog.error(`[Subsonic] Fetch radio songs failed:`, e) } } else if (id === 'love') { musics = listData.loveList @@ -1606,6 +1766,11 @@ class SubsonicHandler { } } + // [dislike] 过滤不喜欢的歌曲 + if (global.lx.config['subsonic.hideDisliked']) { + musics = await this.filterDislikedSongs(username, musics) + } + if (format === 'json') { return this.sendResponse(res, { directory: { @@ -1625,6 +1790,99 @@ class SubsonicHandler { }, format) } + /** + * 补全本地专辑库(library/albums.json)中缺失的曲目列表。 + * + * Subsonic 星标写回原生收藏时只存了名称与封面,没有 list,导致专辑显示 0 首 + * (例如「太阳之子」)。这里对缺 list 的专辑用 albumId 向音源拉一次并写回, + * 使列表的 songCount 与详情页都正确。单次最多补全 MAX_FILL 张,避免拖慢接口。 + */ + private async ensureLibraryAlbumSongs(username: string): Promise { + let libAlbums: any[] + try { + libAlbums = await this.getLibraryData(username, 'albums') + } catch { + return + } + if (!Array.isArray(libAlbums) || libAlbums.length === 0) return + + const MAX_FILL = 20 // 单次请求最多「成功补全」的专辑数 + const MAX_ATTEMPTS = 20 // 单次请求最多发起的回源次数(原实现只统计成功数,音源全挂时会遍历整个库) + const REQ_TIMEOUT = 8000 // 单次回源超时(ms),无超时会挂住整个 getAlbumList + const FAIL_COOLDOWN = 10 * 60 * 1000 // 失败冷却 10 分钟,冷却期内不再重复回源 + + // 带超时的 Promise 包装:超时后清理定时器,避免遗留句柄 + const withTimeout = (p: Promise, ms: number): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms) + Promise.resolve(p).then( + v => { clearTimeout(timer); resolve(v) }, + e => { clearTimeout(timer); reject(e) }, + ) + }) + + let filled = 0 + let attempted = 0 + let dirty = false + const now = Date.now() + + for (const alb of libAlbums) { + if (filled >= MAX_FILL || attempted >= MAX_ATTEMPTS) break + if (Array.isArray(alb?.list) && alb.list.length > 0) continue + const source = alb.source || 'wy' + const albMid = String(alb?.meta?.albumId || alb?.id || '') + if (!albMid) continue + // 失败冷却:近期回源失败的专辑在冷却期内直接跳过,避免每次请求都重复打音源 + const failedAt = Number(alb?.fillFailedAt || 0) + if (failedAt && now - failedAt < FAIL_COOLDOWN) continue + const sdk = (musicSdk as any)[source] + if (!sdk?.extendDetail?.getAlbumSongs) continue + + attempted++ + try { + const data = await withTimeout(sdk.extendDetail.getAlbumSongs(albMid), REQ_TIMEOUT) + const list = (data?.list || []).map((s: any) => ({ + source, + singer: s.singer, + name: s.name, + albumName: s.albumName || alb.name || data.name, + albumId: s.albumMid || albMid, + albumMid: s.albumMid || albMid, + interval: s.interval, + img: s.img, + songId: s.songId, + songmid: s.songmid || s.songId, + })) + if (list.length === 0) { + // 空结果同样视为失败:记录时间戳并写回,冷却期内不再重试 + alb.fillFailedAt = now + dirty = true + continue + } + alb.list = list + if (alb.fillFailedAt !== undefined) delete alb.fillFailedAt + if (!alb.name && data.name) alb.name = data.name + if (!alb.picUrl && list[0]?.img) alb.picUrl = list[0].img + dirty = true + filled++ + } catch (e: any) { + // 记录失败时间,使后续请求在冷却期内跳过该专辑(原实现失败不记忆,会每次重复回源) + alb.fillFailedAt = now + dirty = true + subsonicLog.error(`[Subsonic] 专辑库补全失败 (${source}/${albMid}):`, e?.message) + } + } + + if (dirty) { + try { + await this.writeLibraryData(username, 'albums', libAlbums) + subsonicLog.debug(`[Subsonic] 已补全 ${filled} 张本地专辑的曲目列表 (user=${username})`) + } catch (e) { + subsonicLog.error('[Subsonic] 写回专辑库失败:', e) + } + } + } + private async handleGetAlbumList( res: http.ServerResponse, username: string, @@ -1636,13 +1894,17 @@ class SubsonicHandler { const size = Math.min(parseInt(params.get('size') || '10'), 500) const offset = parseInt(params.get('offset') || '0') if (global.lx.config['subsonic.enableDebug']) { - console.log(`[Subsonic Debug] [AlbumList] type=${type} offset=${offset} size=${size}`) + subsonicLog.debug(`[Subsonic Debug] [AlbumList] type=${type} offset=${offset} size=${size}`) } let albums: any[] = [] - // [推荐逻辑] 根据 type 处理推荐。只有 offset=0 时才展示推荐,便于发现 - // newest/recent/random/byGenre 均走推荐(新专辑/随机推荐),让首页/每日推荐有封面 - if ((type === 'recent' || type === 'newest' || type === 'random' || type === 'byGenre') && offset === 0) { + // [推荐逻辑] 根据 type 处理推荐。 + // 推荐池按固定容量拉取(与客户端请求的 size 无关),翻页时从同一份池里 slice, + // 避免"只有第一页有推荐、第二页突然变成本地收藏"的数据断层。 + const RECOMMEND_POOL_SIZE = Math.max(20, Math.min(500, global.lx.config['subsonic.recommendPoolSize'] ?? 100)) + let recommendPool: any[] = [] + + if (type === 'recent' || type === 'newest' || type === 'random' || type === 'byGenre') { try { if (type === 'byGenre') { const genreNameOrId = params.get('genre') || '' @@ -1654,35 +1916,38 @@ class SubsonicHandler { if (target) categoryId = target.id } if (categoryId) { - albums = await fetchPlaylistsByGenre(categoryId, size) + recommendPool = await fetchPlaylistsByGenre(categoryId, RECOMMEND_POOL_SIZE) } } else { // recent/newest 取新专辑;random 取随机推荐。recent/newest 取不到(空或抛错)时回退到 random const rType = (type === 'random') ? 'random' : 'recent' - let recommendations: any[] = [] try { - recommendations = await cachedRecommend(rType, size) + recommendPool = await cachedRecommend(rType, RECOMMEND_POOL_SIZE) } catch (e) { - console.error(`[Subsonic] recommend (${rType}) failed:`, e) + subsonicLog.error(`[Subsonic] recommend (${rType}) failed:`, e) } - if (recommendations.length === 0 && rType === 'recent') { + if (recommendPool.length === 0 && rType === 'recent') { try { - recommendations = await cachedRecommend('random', size) + recommendPool = await cachedRecommend('random', RECOMMEND_POOL_SIZE) } catch (e) { - console.error(`[Subsonic] recommend fallback (random) failed:`, e) + subsonicLog.error(`[Subsonic] recommend fallback (random) failed:`, e) } } - if (recommendations.length > 0) { - albums = recommendations - } } } catch (e) { - console.error(`[Subsonic] Fetch recommended albums (${type}) failed:`, e) + subsonicLog.error(`[Subsonic] Fetch recommended albums (${type}) failed:`, e) } } - // 如果未命中推荐逻辑,或推荐获取为空,则回退到本地收藏库 - if (albums.length === 0) { + // 分页:推荐池独立翻页,不与本地收藏库拼接。 + // 本地收藏有专门的入口(客户端的收藏/媒体库),无需在推荐列表里重复出现; + // 推荐池翻完即为空,客户端自然停止滚动。 + if (recommendPool.length > 0) { + albums = recommendPool.slice(offset, offset + size) + } else { + // 推荐池为空(type 不是推荐类,或推荐接口失败)→ 回退到本地收藏库 + // 补全本地专辑库缺失的曲目列表(星标回写可能只存了名称/封面,导致 0 首) + await this.ensureLibraryAlbumSongs(username) const libAlbums = await this.getLibraryData(username, 'albums') const buildAlbum = (album: any) => { @@ -1706,8 +1971,7 @@ class SubsonicHandler { } } - const page = libAlbums.slice(offset, offset + size) - albums = page.map(buildAlbum) + albums = libAlbums.slice(offset, offset + size).map(buildAlbum) } const wrapKey = isV2 ? 'albumList2' : 'albumList' @@ -1725,28 +1989,135 @@ class SubsonicHandler { } - private async handleGetArtists(res: http.ServerResponse, username: string, format: string) { - // [修改] 歌手列表首选来自收藏的歌手库 + /** + * 聚合出「歌手维度」目录。 + * + * 歌手来源不再只依赖 library/artists.json(用户显式收藏的歌手,通常只有个位数), + * 而是从本地所有歌曲(我的收藏 / 默认列表 / 全部歌单 / 收藏专辑库)反向聚合, + * 保证音流等客户端的「歌手」标签页有内容且 albumCount / songCount 为真实值。 + */ + private async buildArtistDirectory(username: string) { + const userSpace = getUserSpace(username) + const listData = await userSpace.listManage.getListData() const libArtists = await this.getLibraryData(username, 'artists') - // [修复] 标记已收藏歌手,让 Subsonic 客户端(音流/Ultrasonic 等)能显示爱心高亮 + const libAlbums = await this.getLibraryData(username, 'albums') const meta = await this.getUserSubsonicMeta(username) - const starredArtistSet = new Set(meta.starredArtists) - const starredArtistNameSet = new Set(meta.starredArtistNames) - const isArtistStarred = (source: string, id: string, name: string) => - starredArtistSet.has(`art_${source || 'wy'}_${id}`) || - starredArtistNameSet.has(this.normalizeArtistName(name)) - const artists = libArtists.map(artist => { - const id = `art_${artist.source || 'wy'}_${artist.id}` - const starred = isArtistStarred(artist.source, artist.id, artist.name) - return { - id: id, - name: artist.name, - albumCount: 0, - coverArt: id, - artistImageUrl: artist.picUrl || artist.img, - ...(starred ? { starred: new Date().toISOString() } : {}), + const starredArtistSet = new Set(meta.starredArtists || []) + const starredArtistNameSet = new Set(meta.starredArtistNames || []) + + // 1. 汇总本地所有歌曲 + const allSongs: LX.Music.MusicInfo[] = [] + const pushList = (list: any) => { + for (const m of (list || [])) allSongs.push(m as LX.Music.MusicInfo) + } + pushList(listData.loveList) + pushList(listData.defaultList) + for (const l of (listData.userList || [])) pushList((l as any).list) + // 本地收藏专辑库里的曲目(原始字段,映射成 MusicInfo 兼容格式) + for (const alb of libAlbums) { + const albSource = alb.source || 'wy' + for (const s of (alb.list || [])) { + allSongs.push({ + id: `${s.source || albSource}_${s.songmid || s.songId}`, + name: s.name, + singer: s.singer, + source: s.source || albSource, + songmid: s.songmid, + interval: s.interval || '0', + img: s.img, + meta: { + picUrl: s.img, + albumName: s.albumName || alb.name, + albumId: s.albumMid || alb.id, + }, + } as any) } - }) + } + + // 2. 聚合歌手 + const artistsMap = new Map + songCount: number + starred: boolean + songs: LX.Music.MusicInfo[] + }>() + + const ensure = (id: string, name: string, source: string, singerId: string, picUrl: string) => { + let entry = artistsMap.get(id) + if (!entry) { + entry = { + id, + name, + source, + singerId, + picUrl: picUrl || '', + albumKeys: new Set(), + songCount: 0, + starred: false, + songs: [], + } + artistsMap.set(id, entry) + } + if (!entry.picUrl && picUrl) entry.picUrl = picUrl + return entry + } + + // 2a. 用户显式收藏的歌手(即使名下没有本地曲目也要出现在列表里) + for (const a of libArtists) { + const source = a.source || 'wy' + const id = `art_${source}_${a.id}` + const entry = ensure(id, a.name, source, String(a.id), a.picUrl || a.img || '') + if (starredArtistSet.has(id) || starredArtistNameSet.has(this.normalizeArtistName(a.name))) { + entry.starred = true + } + } + + // 2b. 从本地曲目反推歌手 + for (const m of allSongs) { + const singer = m.singer || '' + const primary = (singer.split('、')[0] || '').trim() + if (!primary || primary === 'Unknown Artist') continue + const source = m.source + const singerId = (m as any).singerId + const id = singerId ? `art_${source}_${singerId}` : `artist_${primary}` + const entry = ensure(id, primary, source, singerId ? String(singerId) : '', (m as any).meta?.picUrl || (m as any).img || '') + + const albumKey = (m as any).meta?.albumName || (m as any).albumName || (m as any).album?.name || '' + if (albumKey && albumKey !== 'Unknown Album') entry.albumKeys.add(albumKey) + entry.songCount += 1 + entry.songs.push(m) + if (starredArtistSet.has(id) || starredArtistNameSet.has(this.normalizeArtistName(primary))) { + entry.starred = true + } + } + + return Array.from(artistsMap.values()) + } + + /** + * 歌手列表接口:收藏歌手 + 本地曲目反推出的歌手聚合,albumCount / songCount 取真实值。 + */ + private async handleGetArtists(res: http.ServerResponse, username: string, format: string) { + // [修改] 歌手列表 = 收藏歌手 + 本地曲目反推出的歌手,albumCount / songCount 取真实值 + const entries = await this.buildArtistDirectory(username) + + const artists = entries + .filter(a => a.name && a.name !== 'Unknown Artist') + .map(a => ({ + id: a.id, + name: a.name, + albumCount: a.albumKeys.size, + songCount: a.songCount, + coverArt: a.id, + artistImageUrl: a.picUrl || undefined, + ...(a.starred ? { starred: new Date().toISOString() } : {}), + })) + .sort((x, y) => x.name.localeCompare(y.name, 'zh-Hans-CN')) // 按首字母分组 const indexMap = new Map() @@ -1816,6 +2187,10 @@ class SubsonicHandler { artistId = id } + // 本地「歌手维度」聚合数据:在线接口取不到时用它兜底,保证歌手页不空白 + const localEntry = (await this.buildArtistDirectory(username)).find(a => a.id === id) + if (localEntry && (!singerName || singerName === 'Unknown')) singerName = localEntry.name + // 定义精准的平台 ID (用于封面和元数据绑定) const resolvedId = (source && artistId && artistId !== id) ? `art_${source}_${artistId}` : id @@ -1826,9 +2201,25 @@ class SubsonicHandler { try { if (musicSdk[source]?.extendDetail) { - // 1. 先抓取专辑列表 (顺序执行以保证稳定性) - const albumData = await musicSdk[source].extendDetail.getArtistAlbums(artistId, 1).catch(() => ({ list: [] })) - const rawAlbums = albumData.list || [] + // 1. 先抓取专辑列表 (多页,最多 5 页 × 50 = 250 张;顺序执行以保证稳定性) + const fetchAllAlbums = async () => { + const MAX_PAGES = 5 + const PAGE_SIZE = 50 + let all: any[] = [] + for (let p = 1; p <= MAX_PAGES; p++) { + try { + const data = await musicSdk[source].extendDetail.getArtistAlbums(artistId, p, PAGE_SIZE) + const pageList = data.list || [] + all = all.concat(pageList) + if (pageList.length < PAGE_SIZE) break + } catch (err) { + subsonicLog.error(`[Subsonic] SDK getArtistAlbums Error at page ${p}:`, err) + break + } + } + return all + } + const rawAlbums = await fetchAllAlbums().catch(() => [] as any[]) // 2. 循环抓取多页歌曲 (最多 5 页,共 500 首) const fetchAllSongs = async () => { @@ -1842,7 +2233,7 @@ class SubsonicHandler { all = all.concat(pageList) if (pageList.length < PAGE_SIZE) break } catch (err) { - console.error(`[Subsonic] SDK getArtistSongs Error at page ${p}:`, err) + subsonicLog.error(`[Subsonic] SDK getArtistSongs Error at page ${p}:`, err) break } } @@ -1864,9 +2255,9 @@ class SubsonicHandler { if (localArt) singerName = localArt.name } - if (albumData.list?.[0]?.singerPic) artistPic = albumData.list[0].singerPic - if (albumData.list?.[0]?.singerName && (singerName === 'Unknown' || !singerName)) { - singerName = albumData.list[0].singerName + if (rawAlbums[0]?.singerPic) artistPic = rawAlbums[0].singerPic + if (rawAlbums[0]?.singerName && (singerName === 'Unknown' || !singerName)) { + singerName = rawAlbums[0].singerName } albums = rawAlbums.map((alb: any) => ({ @@ -1888,7 +2279,39 @@ class SubsonicHandler { })) } } catch (e) { - console.error(`[Subsonic] SDK Artist load error:`, e) + subsonicLog.error(`[Subsonic] SDK Artist load error:`, e) + } + + // [歌手维度兜底] 在线数据缺失时,用本地聚合出的专辑 / 曲目填充,避免歌手页空白 + if (localEntry) { + if (albums.length === 0) { + const groupMap = new Map() + for (const m of localEntry.songs) { + const key = (m as any).meta?.albumName || (m as any).albumName || '未知专辑' + if (!groupMap.has(key)) groupMap.set(key, []) + groupMap.get(key)!.push(m) + } + albums = Array.from(groupMap.entries()).map(([name, songs]) => { + const first = songs[0] as any + const albumMid = first.meta?.albumId || first.albumMid || first.album?.mid + // 有 albumMid 就用 alb_ 前缀(点开走 SDK 补全整张专辑),否则退回聚合 ID + const albId = albumMid + ? `alb_${first.source || source}_${albumMid}` + : `album_${Buffer.from(`${name}__${singerName}`).toString('base64url').slice(0, 24)}` + return { + id: albId, + name, + title: name, + album: name, + artist: singerName, + artistId: resolvedId, + songCount: songs.length, + coverArt: first.meta?.picUrl || first.img || albId, + isDir: true, + } + }) + } + if (hotSongs.length === 0) hotSongs = localEntry.songs } /* @@ -1905,6 +2328,11 @@ class SubsonicHandler { } */ + // [dislike] 歌手页的热门歌曲同样过滤 + if (global.lx.config['subsonic.hideDisliked']) { + hotSongs = await this.filterDislikedSongs(username, hotSongs) + } + const artistStarred = starredArtistSet.has(resolvedId) || starredArtistNameSet.has(this.normalizeArtistName(singerName)) const artistInfo = { @@ -1974,7 +2402,7 @@ class SubsonicHandler { private async handleGetGenres(res: http.ServerResponse, username: string, format: string) { const genres = await fetchGenres() - // console.log(`[Subsonic] handleGetGenres found ${genres.length} genres`) + // subsonicLog.debug(`[Subsonic] handleGetGenres found ${genres.length} genres`) if (format === 'json') { return this.sendResponse(res, { genres: { genre: genres } }, format) } @@ -2069,7 +2497,7 @@ class SubsonicHandler { results.push({ music, listId: 'online' }) } } catch (err: any) { - console.error(`[Subsonic] Online search error for source=${source}:`, err?.message || err) + subsonicLog.error(`[Subsonic] Online search error for source=${source}:`, err?.message || err) } })) @@ -2272,6 +2700,11 @@ class SubsonicHandler { created: new Date().toISOString(), playCount: 0, }) + } else { + // [修复] 同一专辑的多首收藏需要累加,否则 songCount 恒为 1 + const exist = allAlbumsMap.get(albId)! + exist.songCount += 1 + exist.duration += this.parseDuration(music.interval) } } } @@ -2332,6 +2765,17 @@ class SubsonicHandler { } } + // [dislike] 命中不喜欢规则的歌曲从搜索结果中剔除(放在分页之前,避免过滤后每页数量不足) + // matchedSongs 的元素是 { music, listId },需取出 music 过滤后再映射回去 + if (global.lx.config['subsonic.hideDisliked']) { + const entries = matchedSongs as Array<{ music: LX.Music.MusicInfo, listId: string }> + const kept = await this.filterDislikedSongs(username, entries.map(e => e.music)) + if (kept.length !== entries.length) { + const keptIds = new Set(kept.map(m => m.id)) + matchedSongs = entries.filter(e => keptIds.has(e.music.id)) as any + } + } + const pagedArtists = artistCount > 0 ? matchedArtists.slice(artistOffset, artistOffset + artistCount) : [] const pagedAlbums = albumCount > 0 ? matchedAlbums.slice(albumOffset, albumOffset + albumCount) : [] const pagedSongs = songCount > 0 ? matchedSongs.slice(songOffset, songOffset + songCount) : [] @@ -2458,17 +2902,21 @@ class SubsonicHandler { return null } if (!music) { - console.log(`[Subsonic][trace] resolveMusicById ${id}: 回源未取到音乐信息(source=${source})`) + if (global.lx.config['subsonic.enableDebug']) { + subsonicLog.debug(`[Subsonic][trace] resolveMusicById ${id}: 回源未取到音乐信息(source=${source})`) + } return null } if (!music.source) music.source = source if (!music.id) music.id = `${source}_${music.songmid || music.songId || songId}` if (!music.songmid) music.songmid = music.songId || songId if (!music.name) { - console.warn(`[Subsonic] resolveMusicById ${id} 取回结果缺少歌名,已放弃(不写入收藏)`) + subsonicLog.warn(`[Subsonic] resolveMusicById ${id} 取回结果缺少歌名,已放弃(不写入收藏)`) return null } - console.log(`[Subsonic][trace] resolveMusicById ${id}: 成功取到 name=${music.name}, singer=${music.singer || '(空)'}`) + if (global.lx.config['subsonic.enableDebug']) { + subsonicLog.debug(`[Subsonic][trace] resolveMusicById ${id}: 成功取到 name=${music.name}, singer=${music.singer || '(空)'}`) + } return music as LX.Music.MusicInfo } catch (e) { this.logSourceError('resolveMusicById', `回源 ${id}`, e) @@ -2479,21 +2927,21 @@ class SubsonicHandler { /** 统一元数据解析原语:本地/缓存 -> 在线回源;命中即回写 onlineSongCache */ private async resolveSongMeta(username: string, id: string): Promise<{ music: LX.Music.MusicInfo, listId: string } | null> { const debug = !!global.lx.config['subsonic.enableDebug'] - if (debug) console.log(`[Subsonic][trace] resolveSongMeta ${id}: 第1步 查持久化(歌单/专辑库) + 内存/磁盘 onlineSongCache`) + if (debug) subsonicLog.debug(`[Subsonic][trace] resolveSongMeta ${id}: 第1步 查持久化(歌单/专辑库) + 内存/磁盘 onlineSongCache`) const found = await this.findMusicById(username, id) if (found) { this.cacheOnlineSong(found.music) - if (debug) console.log(`[Subsonic][trace] resolveSongMeta ${id}: 第1步命中 listId=${found.listId}, name=${found.music.name || '(空)'}`) + if (debug) subsonicLog.debug(`[Subsonic][trace] resolveSongMeta ${id}: 第1步命中 listId=${found.listId}, name=${found.music.name || '(空)'}`) return found } - if (debug) console.log(`[Subsonic][trace] resolveSongMeta ${id}: 第1步未命中 -> 第2步 在线回源(network)`) + if (debug) subsonicLog.debug(`[Subsonic][trace] resolveSongMeta ${id}: 第1步未命中 -> 第2步 在线回源(network)`) const resolved = await this.resolveMusicById(id) if (resolved) { this.cacheOnlineSong(resolved) - if (debug) console.log(`[Subsonic][trace] resolveSongMeta ${id}: 第2步回源成功 name=${resolved.name}, 已写 onlineSongCache(内存+磁盘)`) + if (debug) subsonicLog.debug(`[Subsonic][trace] resolveSongMeta ${id}: 第2步回源成功 name=${resolved.name}, 已写 onlineSongCache(内存+磁盘)`) return { music: resolved, listId: 'online' } } - if (debug) console.log(`[Subsonic][trace] resolveSongMeta ${id}: 第2步回源失败 -> 返回 null(将在 stream 中降级为 Unknown)`) + if (debug) subsonicLog.debug(`[Subsonic][trace] resolveSongMeta ${id}: 第2步回源失败 -> 返回 null(将在 stream 中降级为 Unknown)`) return null } @@ -2564,6 +3012,14 @@ class SubsonicHandler { const location = (global.lx.config['list.addMusicLocationType'] || 'bottom') as 'top' | 'bottom' let loveChanged = false let metaChanged = false + // 记录本次操作的增量。循环里存在网络 await(解析专辑/歌手/歌曲), + // 不能长时间持锁,因此只在写回时加锁,并把增量与「最新 meta」合并,避免覆盖并发写入。 + const addedAlbums = new Set() + const removedAlbums = new Set() + const addedArtists = new Set() + const removedArtists = new Set() + const addedArtistNames = new Set() + const removedArtistNames = new Set() const action = isStar ? 'star' : 'unstar' const debug = !!global.lx.config['subsonic.enableDebug'] const debugLog = (msg: string) => { if (debug) console.log(msg) } @@ -2576,9 +3032,9 @@ class SubsonicHandler { for (const id of ids) { if (id.startsWith('alb_')) { - isStar ? starredAlbums.add(id) : starredAlbums.delete(id) + if (isStar) { starredAlbums.add(id); addedAlbums.add(id) } else { starredAlbums.delete(id); removedAlbums.add(id) } metaChanged = true - console.log(`[Subsonic] ${action} 专辑 ${id} (user=${username})`) + subsonicLog.debug(`[Subsonic] ${action} 专辑 ${id} (user=${username})`) debugLog(`[Subsonic Debug] ${action} 专辑 ${id} -> ${isStar ? '已星标' : '已取消星标'} (user=${username})`) // 双向同步:写回原生媒体库收藏 const m = id.match(/^alb_([a-zA-Z0-9]+)_(.+)$/) @@ -2592,7 +3048,7 @@ class SubsonicHandler { nativeAlbums.push({ id: realId, source, name, picUrl, artistName: '' }) nativeAlbumsDirty = true } else { - console.warn(`[Subsonic] ${action} 专辑 ${id} 跳过原生同步:无法解析专辑名 (user=${username})`) + subsonicLog.warn(`[Subsonic] ${action} 专辑 ${id} 跳过原生同步:无法解析专辑名 (user=${username})`) } } } else { @@ -2606,16 +3062,16 @@ class SubsonicHandler { // 统一解析:art_规范id 或 artist_名字兜底id -> 规范id + 歌手名 const { canonical, name } = await this.resolveArtistKey(username, id) if (!canonical) { - console.warn(`[Subsonic] ${action} 歌手 ${id} 跳过:无法解析歌手标识,未做任何改动 (user=${username})`) + subsonicLog.warn(`[Subsonic] ${action} 歌手 ${id} 跳过:无法解析歌手标识,未做任何改动 (user=${username})`) continue } - isStar ? starredArtists.add(canonical) : starredArtists.delete(canonical) + if (isStar) { starredArtists.add(canonical); addedArtists.add(canonical) } else { starredArtists.delete(canonical); removedArtists.add(canonical) } if (name) { const nk = this.normalizeArtistName(name) - isStar ? starredArtistNames.add(nk) : starredArtistNames.delete(nk) + if (isStar) { starredArtistNames.add(nk); addedArtistNames.add(nk) } else { starredArtistNames.delete(nk); removedArtistNames.add(nk) } } metaChanged = true - console.log(`[Subsonic] ${action} 歌手 ${id} -> 规范=${canonical}${name ? `, 名=${name}` : ''} (user=${username})`) + subsonicLog.debug(`[Subsonic] ${action} 歌手 ${id} -> 规范=${canonical}${name ? `, 名=${name}` : ''} (user=${username})`) debugLog(`[Subsonic Debug] ${action} 歌手 ${id} -> ${isStar ? '已星标' : '已取消星标'} (规范=${canonical}${name ? `, 名=${name}` : ''}) (user=${username})`) // 双向同步:写回原生媒体库收藏(仅规范 art_ 前缀可映射回原生 id) const m = canonical.match(/^art_([a-zA-Z0-9]+)_(.+)$/) @@ -2639,7 +3095,7 @@ class SubsonicHandler { const hit = await this.resolveSongMeta(username, id) const resolved = hit?.music || null if (!resolved) { - console.warn(`[Subsonic] ${action} 歌曲 ${id} 跳过:findMusicById 未命中 且 resolveMusicById 失败,未做任何改动 (user=${username})`) + subsonicLog.warn(`[Subsonic] ${action} 歌曲 ${id} 跳过:findMusicById 未命中 且 resolveMusicById 失败,未做任何改动 (user=${username})`) continue } if (!this.loveIdSets.has(username)) this.loveIdSets.set(username, new Set()) @@ -2655,16 +3111,23 @@ class SubsonicHandler { this.cacheOnlineSong(resolved) loveChanged = true } catch (e) { - console.error(`[Subsonic] ${action} song error (${id}):`, e) + subsonicLog.error(`[Subsonic] ${action} song error (${id}):`, e) } } if (metaChanged) { - await this.saveUserSubsonicMeta(username, { - starredAlbums: Array.from(starredAlbums), - starredArtists: Array.from(starredArtists), - starredArtistNames: Array.from(starredArtistNames), - ratings: meta.ratings, + await withMetaLock(username, async () => { + // 与「写回时刻的最新 meta」合并,而不是用循环开始前读到的旧快照整体覆盖, + // 否则会丢失并发的 star / setRating 写入。 + const fresh = await this.getUserSubsonicMeta(username) + const merge = (base: string[], add: Set, del: Set): string[] => + Array.from(new Set([...(base || []).filter((x: string) => !del.has(x)), ...add])) + await this.saveUserSubsonicMeta(username, { + starredAlbums: merge(fresh.starredAlbums, addedAlbums, removedAlbums), + starredArtists: merge(fresh.starredArtists, addedArtists, removedArtists), + starredArtistNames: merge(fresh.starredArtistNames, addedArtistNames, removedArtistNames), + ratings: fresh.ratings, + }) }) } if (nativeArtistsDirty) await this.writeLibraryData(username, 'artists', nativeArtists) @@ -2673,7 +3136,7 @@ class SubsonicHandler { try { await userSpace.listManage.createSnapshot() } catch (e) { - console.error('[Subsonic] createSnapshot error:', e) + subsonicLog.error('[Subsonic] createSnapshot error:', e) } } @@ -2681,6 +3144,305 @@ class SubsonicHandler { return this.sendResponse(res, {}, format) } + /** + * 设置歌曲评分 (Subsonic setRating) + * 参数: id(歌曲id), rating(0-5, 0 表示清除评分) + * 评分按用户持久化到 subsonic meta 的 ratings 映射中。 + */ + // ───────────────────────────────────────────── + // dislike 联动(Subsonic 评分 → lx-music 原生「不喜欢」规则) + // 规则形态(与 modules/dislike/utils.ts 的 filterRules 保持一致): + // 歌名@歌手 精确屏蔽一首歌 + // 歌名 屏蔽所有同名(翻唱 / 多版本) + // @歌手 屏蔽该歌手全部歌曲 + // !:@ 专辑维度(lxserver 扩展,老客户端向后兼容) + // ───────────────────────────────────────────── + + /** 把 (歌名, 歌手) 归一化成规则行 key,规则同 filterRules */ + private dislikeRuleKey(name: string, singer: string): string { + const n = (name || '').replaceAll('@', '#').trim().toLowerCase() + const s = (singer || '').replaceAll('@', '#').trim().toLowerCase() + if (n && s) return `${n}@${s}` + if (n) return n + if (s) return `@${s}` + return '' + } + + /** 读取当前用户的 dislike 规则集(已解析出歌曲 / 歌手 / 专辑三维,带解析缓存) */ + private async getDislikeRuleSet(username: string): Promise { + const userSpace = getUserSpace(username) as any + return getCachedDislikeRuleSet(username, async () => { + return userSpace.dislikeManage?.dislikeDataManage?.getDislikeRulesString() || '' + }) + } + + /** 批量写入 dislike 规则(infos 可携带完整 MusicInfo 字段,原样落盘到 snapshot) */ + private async addDislikeRules(username: string, infos: LX.Dislike.DislikeSongInfo[]): Promise { + const valid = infos.filter(i => this.dislikeRuleKey(i.name, i.singer)) + if (valid.length === 0) return 0 + try { + const userSpace = getUserSpace(username) as any + await userSpace.dislikeManage.dislikeDataManage.addDislikeInfo(valid) + await userSpace.dislikeManage.createSnapshot() + invalidateDislikeCache(username) + return valid.length + } catch (e) { + subsonicLog.error('[Subsonic] 写入 dislike 规则失败:', e) + return 0 + } + } + + /** 写入专辑维度的 dislike 规则(!<专辑名>@<歌手>,按歌手拆多条) */ + private async addDislikeAlbum(username: string, album: { albumName: string, singers: string[] }): Promise { + try { + const userSpace = getUserSpace(username) as any + const infos = (album.singers || []).map(singer => ({ albumName: album.albumName, singer })) + if (infos.length === 0) return 0 + await userSpace.dislikeManage.dislikeDataManage.addDislikeAlbums(infos) + await userSpace.dislikeManage.createSnapshot() + invalidateDislikeCache(username) + return infos.length + } catch (e) { + subsonicLog.error('[Subsonic] 写入专辑 dislike 失败:', e) + return 0 + } + } + + /** 按规则行 key 移除 dislike(原生没有单条删除,只能整体覆盖) */ + private async removeDislikeRules(username: string, keys: Set): Promise { + if (keys.size === 0) return 0 + try { + // 规则串已被 filterRules 统一小写,比较时一并小写 + const lowerKeys = new Set(Array.from(keys).map(k => k.trim().toLowerCase()).filter(Boolean)) + const userSpace = getUserSpace(username) as any + const rules = userSpace.dislikeManage.dislikeDataManage.getDislikeRulesString() + const lines = String(rules || '').split('\n').filter(l => l.trim()) + const remain = lines.filter(l => !lowerKeys.has(l.trim().toLowerCase())) + const removed = lines.length - remain.length + if (removed > 0) { + await userSpace.dislikeManage.dislikeDataManage.overwirteDislikeInfo(remain.join('\n')) + await userSpace.dislikeManage.createSnapshot() + invalidateDislikeCache(username) + } + return removed + } catch (e) { + subsonicLog.error('[Subsonic] 移除 dislike 规则失败:', e) + return 0 + } + } + + /** + * 把 Subsonic 的 id 解析成 dislike 目标(歌曲 / 歌手 / 专辑三个维度): + * - 歌曲 id → 单条「歌名@歌手」 + * - art_/artist_ → 单条「@歌手」,屏蔽该歌手全部歌曲 + * - alb_ 专辑 → 专辑规则「!:@」,不展开成曲目 + * + * 同时返回规则行 key 集合,供写入与移除共用。 + */ + /** + * 按优先级链补全歌手的 source + id: + * 1. 入口自带(art_ 前缀的 id 本身就含 source + 歌手 ID) + * 2. 本地收藏歌手库 library/artists.json + * 3. 按名字在线查询 getSingerMid(tx / wy,有同名风险) + * 都拿不到时返回空,退回纯名字规则。 + */ + private async resolveSingerIdentity( + username: string, + name: string, + preset?: { source: string, id: string }, + ): Promise<{ name: string, source: string, singerId: string }> { + if (preset?.id) return { name, source: preset.source, singerId: preset.id } + if (!name) return { name, source: '', singerId: '' } + + // 2. 本地收藏歌手库 + try { + const lib = await this.getLibraryData(username, 'artists') + const hit = lib.find((a: any) => this.normalizeArtistName(a.name) === this.normalizeArtistName(name)) + if (hit?.id) return { name, source: hit.source || 'wy', singerId: String(hit.id) } + } catch { /* 库不可读不阻断 */ } + + // 3. 在线按名查询(tx / wy) + try { + const mid = await getSingerMid(name) + if (mid) return { name, source: 'tx', singerId: mid } + } catch { /* 查询失败不阻断 */ } + + return { name, source: '', singerId: '' } + } + + /** + * 根据 Subsonic 歌曲 id 解析出对应的「歌名 / 歌手 / 规则 key」,供写回 dislike 使用。 + */ + private async resolveDislikeTarget(username: string, id: string): Promise<{ + infos: Array<{ name: string, singer: string }> + album?: { albumName: string, singers: string[] } + singer?: { name: string, source: string, singerId: string } + keys: Set + }> { + // 歌手维度 + if (id.startsWith('art_') || id.startsWith('artist_')) { + let name = '' + let preset: { source: string, id: string } | undefined + + if (id.startsWith('artist_')) { + // 兜底 id:只有歌手名,需要走补全链 + name = decodeURIComponent(id.slice(7)) + } else { + // art_ 前缀:id 本身就含 source + 歌手 ID,不要只取名字把 ID 丢掉 + const m = id.match(/^art_([a-zA-Z0-9]+)_(.+)$/) + if (m) preset = { source: m[1], id: m[2] } + name = (await this.resolveArtistKey(username, id)).name || '' + } + + const identity = await this.resolveSingerIdentity(username, name, preset) + const infos = identity.name ? [{ name: '', singer: identity.name }] : [] + return { + infos, + singer: identity, + keys: new Set(infos.map(i => this.dislikeRuleKey(i.name, i.singer)).filter(Boolean)), + } + } + + // 专辑维度(排除虚拟歌单 alb_tx_playlist_) + if (id.startsWith('alb_') && !id.startsWith('alb_tx_playlist_')) { + const parts = id.split('_') + const source = parts[1] + const albumId = parts.slice(2).join('_') + if (!source || !albumId) return { infos: [], keys: new Set() } + // 取专辑名 + 全部歌手(合辑多位歌手):按歌手拆多条写入,匹配时由开关控制 contains/exact。 + let albumName = '' + const singerSet = new Set() + try { + const sdk = (musicSdk as any)[source] + const data = await sdk?.extendDetail?.getAlbumSongs?.(albumId) + const list: any[] = data?.list || [] + albumName = normalizeText(String(data?.name || list?.[0]?.album?.name || '').trim()) + for (const s of list) { + const raw = String(s?.singer || s?.singerName || s?.album?.singer || '').trim() + if (!raw) continue + for (const n of splitSingers(raw)) singerSet.add(n) + } + } catch { /* 取不到不阻断,仅少一层校验 */ } + if (!albumName) return { infos: [], keys: new Set() } + const singers = [...singerSet] + const keys = new Set(singers.map(s => encodeAlbumRule(albumName, s))) + const album = { albumName, singers } + return { infos: [], album, keys } + } + + // 歌曲维度 + const hit = await this.resolveSongMeta(username, id) + // 构造完整的 DislikeSongInfo,保留 MusicInfo 所有原始字段(img/types/_types/songmid 等), + // 使 snapshot 条目与 lx-music 客户端写入的格式一致,方便客户端侧展示/管理。 + const infos: LX.Dislike.DislikeSongInfo[] = hit?.music ? [{ + ...(hit.music as any), // 展开所有 MusicInfo 字段(songmid/img/types/_types/meta 等) + name: hit.music.name || '', + singer: hit.music.singer || '', + source: hit.music.source, + interval: hit.music.interval, + pic: (hit.music as any).img || (hit.music as any).meta?.picUrl || undefined, + }] : [] + let singerIdentity: { name: string, source: string, singerId: string } | undefined + if (hit?.music) { + const rawSingerId = (hit.music as any).singerId + singerIdentity = await this.resolveSingerIdentity( + username, + (hit.music.singer || '').split('、')[0] || '', + rawSingerId ? { source: hit.music.source, id: String(rawSingerId) } : undefined, + ) + } + return { + infos, + singer: singerIdentity, + keys: new Set(infos.map(i => this.dislikeRuleKey(i.name, i.singer)).filter(Boolean)), + } + } + + /** 按 dislike 规则过滤歌曲列表(歌曲 / 歌手 / 专辑三维) */ + private async filterDislikedSongs(username: string, musics: LX.Music.MusicInfo[]): Promise { + if (!musics || musics.length === 0) return musics + const ruleSet = await this.getDislikeRuleSet(username) + if (!ruleSet) return musics + const crossSource = global.lx.config['subsonic.dislikeCrossSource'] === true + const duetMode = (global.lx.config['subsonic.dislikeDuetMode'] || 'any') as 'any' | 'all' | 'primary' + const normalizeName = global.lx.config['subsonic.dislikeNormalizeName'] !== false + const requireSinger = global.lx.config['subsonic.dislikeRequireSinger'] !== false + return filterDisliked(musics, ruleSet, (m) => ({ + name: m.name, + singer: m.singer, + source: m.source, + albumId: (m as any).meta?.albumId ?? (m as any).albumId ?? (m as any).album?.id, + albumName: (m as any).meta?.albumName, + }), { crossSource, duetMode, normalizeName, requireSinger }) + } + + /** 按 dislike 规则过滤 { music, listId } 形式的列表(随机 / 搜索 / 相似等入口) */ + private async filterDislikedEntries(username: string, entries: Array<{ music: LX.Music.MusicInfo, listId: string }>) { + if (!entries || entries.length === 0) return entries + const kept = await this.filterDislikedSongs(username, entries.map(e => e.music)) + if (kept.length === entries.length) return entries + const keptIds = new Set(kept.map(m => m.id)) + return entries.filter(e => keptIds.has(e.music.id)) + } + + /** + * 处理 Subsonic setRating:写回评分,并按配置把低分(<=阈值)联动为「不喜欢」。 + */ + private async handleSetRating(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) + const rating = parseInt(params.get('rating') || '0', 10) + if (isNaN(rating) || rating < 0 || rating > 5) { + return this.sendError(res, 0, 'Rating must be between 0 and 5', format) + } + // 读-改-写整体串行化,避免与并发的 star / dislike 联动互相覆盖 + await withMetaLock(username, async () => { + const meta = await this.getUserSubsonicMeta(username) + if (rating === 0) { + delete meta.ratings[id] + } else { + meta.ratings[id] = rating + } + await this.saveUserSubsonicMeta(username, meta) + // [修复] 立即刷新内存评星缓存,保证本次请求之后的歌曲序列化能带上最新 userRating + this.userRatingsCache.set(username, meta.ratings || {}) + }) + subsonicLog.debug(`[Subsonic] setRating ${id} -> ${rating} (user=${username})`) + + // [dislike 联动] 0 < rating <= dislikeRating 视为「不喜欢」,写回 lx-music 原生 dislike 规则; + // 提高评分或清除评分则移除此前写入的规则。需 linkRatingToDislike 开启才联动,否则仅记录评分。 + const threshold = global.lx.config['subsonic.dislikeRating'] ?? 1 + if (global.lx.config['subsonic.linkRatingToDislike'] && threshold > 0) { + try { + const target = await this.resolveDislikeTarget(username, id) + // [调试] 打印解析到的维度与身份信息,便于确认各入口能拿到什么 + if (global.lx.config['subsonic.enableDebug']) { + subsonicLog.debug(`[Subsonic] dislike 解析 id=${id}`, JSON.stringify({ + dimension: target.album ? 'album' : (id.startsWith('art_') || id.startsWith('artist_') ? 'singer' : 'song'), + song: target.infos, + singer: target.singer || null, + album: target.album || null, + keys: Array.from(target.keys), + })) + } + if (rating > 0 && rating <= threshold) { + let added = await this.addDislikeRules(username, target.infos) + if (target.album) added += await this.addDislikeAlbum(username, target.album) + subsonicLog.debug(`[Subsonic] setRating 联动 dislike:写入 ${added} 条规则 (id=${id}, user=${username})`) + } else { + const removed = await this.removeDislikeRules(username, target.keys) + if (removed > 0) { + subsonicLog.debug(`[Subsonic] setRating 联动 dislike:移除 ${removed} 条规则 (id=${id}, user=${username})`) + } + } + } catch (e) { + subsonicLog.error('[Subsonic] setRating 联动 dislike 失败:', e) + } + } + + return this.sendResponse(res, {}, format) + } + private async handleGetStarred(res: http.ServerResponse, username: string, format: string, isV2 = true) { const userSpace = getUserSpace(username) const listData = await userSpace.listManage.getListData() @@ -2743,12 +3505,18 @@ class SubsonicHandler { } }) + // [dislike] 收藏列表同样过滤不喜欢的歌曲 + let filteredSongs = allSongs + if (global.lx.config['subsonic.hideDisliked']) { + filteredSongs = await this.filterDislikedEntries(username, allSongs) + } + const wrapKey = isV2 ? 'starred2' : 'starred' if (format === 'json') { return this.sendResponse(res, { [wrapKey]: { - song: allSongs.map(item => this.musicToSongFlat(item.music, item.listId, undefined, username)), + song: filteredSongs.map(item => this.musicToSongFlat(item.music, item.listId, undefined, username)), album: mappedAlbums, artist: mappedArtists, }, @@ -2757,7 +3525,7 @@ class SubsonicHandler { return this.sendResponse(res, { [wrapKey]: { children: { - song: allSongs.map(item => this.musicToSongXml(item.music, item.listId, undefined, username)), + song: filteredSongs.map(item => this.musicToSongXml(item.music, item.listId, undefined, username)), album: mappedAlbums.map(a => ({ attrs: a })), artist: mappedArtists.map(a => ({ attrs: a })), }, @@ -2799,7 +3567,7 @@ class SubsonicHandler { return this.renderRandomSongs(res, picked, format, rootKey, username) } } catch (e) { - console.error(`[Subsonic] fetchSongsByGenre failed:`, e) + subsonicLog.error(`[Subsonic] fetchSongsByGenre failed:`, e) } } @@ -2821,23 +3589,62 @@ class SubsonicHandler { return this.renderRandomSongs(res, picked, format, rootKey, username) } - private renderRandomSongs(res: http.ServerResponse, picked: { music: LX.Music.MusicInfo, listId: string }[], format: string, rootKey: string = 'randomSongs', username?: string) { + private async renderRandomSongs(res: http.ServerResponse, picked: { music: LX.Music.MusicInfo, listId: string }[], format: string, rootKey: string = 'randomSongs', username?: string) { + // [dislike] 随机歌曲 / 流派歌曲 / 每日推荐等入口共用此渲染。 + // 这里受 dislikeNoRecommend 控制(与列表剔除 hideDisliked 相互独立): + // 即使不隐藏歌曲,也可以让它们不再出现在推荐里。 + let list = picked + if (username && global.lx.config['subsonic.dislikeNoRecommend']) { + list = await this.filterDislikedEntries(username, picked) + } + if (format === 'json') { return this.sendResponse(res, { [rootKey]: { - song: picked.map(({ music, listId }) => this.musicToSongFlat(music, listId, undefined, username)), + song: list.map(({ music, listId }) => this.musicToSongFlat(music, listId, undefined, username)), }, }, format) } return this.sendResponse(res, { [rootKey]: { children: { - song: picked.map(({ music, listId }) => this.musicToSongXml(music, listId, undefined, username)), + song: list.map(({ music, listId }) => this.musicToSongXml(music, listId, undefined, username)), }, }, }, format) } + /** + * 每日推荐歌曲 (lx-server 扩展接口) + * 对应 Subsonic 客户端里常见的「每日推荐 / For You」入口。非官方标准方法, + * 通过 getRecommendedSongs / getDailySongs 调用(getSongsByTag 作为别名同样映射到此)。 + * 返回当天稳定的推荐歌曲列表,每首均为可直接播放的在线歌曲。 + */ + private async handleGetRecommendedSongs( + res: http.ServerResponse, + username: string, + params: URLSearchParams, + format: string, + ) { + const size = Math.min(parseInt(params.get('size') || '20'), 100) + try { + // 取当天全部候选(最多 100),再按「不喜欢」阈值排除:评分落在 (0, threshold] 视为不喜欢。 + // 阈值取自 subsonic.dislikeRating(可配置),避免此处硬编码导致管理员改阈值后语义不一致。 + const all = await fetchRecommendedSongs(100) + const meta = await this.getUserSubsonicMeta(username) + const threshold = global.lx.config['subsonic.dislikeRating'] ?? 1 + const songs = all.filter((s: any) => { + const r = meta.ratings[s.id] || 0 + return !(threshold > 0 && r > 0 && r <= threshold) + }).slice(0, size) + const picked = songs.map((s: any) => ({ music: s, listId: 'recommended' })) + return this.renderRandomSongs(res, picked, format, 'recommendedSongs', username) + } catch (e) { + subsonicLog.error('[Subsonic] getRecommendedSongs 出错:', e) + return this.renderRandomSongs(res, [], format, 'recommendedSongs', username) + } + } + private async handleGetSimilarSongs( res: http.ServerResponse, username: string, @@ -2876,7 +3683,12 @@ class SubsonicHandler { const j = Math.floor(Math.random() * (i + 1)); [candidates[i], candidates[j]] = [candidates[j], candidates[i]] } - const picked = candidates.slice(0, count) + let picked = candidates.slice(0, count) + + // [dislike] 相似歌曲属于推荐性质,受 dislikeNoRecommend 控制 + if (global.lx.config['subsonic.dislikeNoRecommend']) { + picked = await this.filterDislikedEntries(username, picked) + } const wrapKey = 'similarSongs2' @@ -2896,6 +3708,159 @@ class SubsonicHandler { }, format) } + /** + * 按音质优先级 + 平台优选解析音流(仅用于 Subsonic stream)。 + * 不是"失败降级",而是主动按优先级选择最优可用方案: + * - 音质优先级:subsonic.quality.priority(默认 flac > 320k > 128k),受客户端 maxBitrate 上界约束。 + * clientCapMode=hard 时仅选 ≤ maxBitrate 的最高优先级音质;soft 时若 ≤ 上限都取不到,再向上突破选更高优先级。 + * - 平台优选:客户端所选源(requested)必优先;若开启跨平台,再按 subsonic.source.priority 顺序优选其它平台。 + * - 同源多脚本:subsonic.source.autoSwitchCustom 开启时,callUserApiGetMusicUrl 内部循环同平台所有自定义源脚本。 + * 客户端规则(source 选择 / maxBitrate 上界)始终优先于后台默认值;subsonic.quality.enabled=false 时退化为单次解析。 + */ + /** + * 计算某平台在给定客户端 maxBitrate 下的音质尝试顺序(高优先级在前): + * - 优先用该平台的逐源覆盖 subsonic.quality.sources..priority,否则用全局 subsonic.quality.priority。 + * - clientCapMode=hard:仅保留 ≤ maxBitrate 的音质;soft:先 ≤ 上限、再突破上限选更高优先级。 + * 该顺序同时供源站回源(resolveStreamUrl)与本地缓存探测(playCacheFirst)复用,保证两者一致。 + */ + private getQualityPriorityOrder(src: string, maxBitrate: number): string[] { + const cfg = global.lx.config + const asList = (v: any, fb: string[]): string[] => { + const arr = Array.isArray(v) ? v.map(String) : String(v || '').split(',').map((s: string) => s.trim()).filter(Boolean) + return arr.length ? arr : fb + } + const globalPriority = asList(cfg['subsonic.quality.priority'], ['flac', '320k', '128k']) + const srcOverride = (cfg['subsonic.quality.sources'] as any)?.[src] + const pri = asList(srcOverride, globalPriority) + const cap = maxBitrate === 0 || maxBitrate >= 320 ? Infinity : maxBitrate + const KBPS: Record = { master: 9999, hires: 9999, flac24bit: 9999, flac: 9999, '320k': 320, '192k': 192, '128k': 128 } + const withinCap = (q: string) => (KBPS[q] ?? 128) <= cap + const inCap = pri.filter(withinCap) + const overCap = pri.filter(q => !withinCap(q)) + if (cfg['subsonic.quality.clientCapMode'] === 'soft') return [...inCap, ...overCap] + // hard 模式:当 maxBitrate 低于所有已知音质(如 64/96 < 最小 128)时 inCap 为空, + // 会让音质循环与缓存探测循环都不执行,最终 stream 抛「所有候选失败」。 + // 此时回退到优先级列表中码率最低的一项,保证至少保留一个候选。 + if (inCap.length === 0 && pri.length > 0) { + return [pri.reduce((lo, q) => (KBPS[q] ?? 128) < (KBPS[lo] ?? 128) ? q : lo)] + } + return inCap + } + + private async resolveStreamUrl( + source: string, + songmid: string, + id: string, + musicInfo: any, + requestedQuality: string, + maxBitrate: number, + username: string, + ): Promise<{ url: string, quality: string, selected?: string }> { + const cfg = global.lx.config + // 总开关关闭 → 退化为单次解析(服务端硬策略,客户端无法 override 打开) + if (cfg['subsonic.quality.enabled'] === false) { + const r = await callUserApiGetMusicUrl(source as any, musicInfo as any, requestedQuality, username) + if (!r?.url) throw new Error('Could not resolve music URL') + return { url: r.url, quality: r.type || requestedQuality } + } + + const asList = (v: any, fb: string[]): string[] => { + const arr = Array.isArray(v) ? v.map(String) : String(v || '').split(',').map((s: string) => s.trim()).filter(Boolean) + return arr.length ? arr : fb + } + const autoSwitchCustom = cfg['subsonic.source.autoSwitchCustom'] !== false + + // 平台优选顺序:客户端所选源优先,其余按 subsonic.source.priority + const sourcesToTry: string[] = [source] + if (cfg['subsonic.source.crossPlatform'] !== false) { + const srcPriority: string[] = asList(cfg['subsonic.source.priority'], ['kw', 'tx', 'wy', 'mg', 'kg']) + for (const s of srcPriority) if (s !== source && !sourcesToTry.includes(s)) sourcesToTry.push(s) + } + + for (const trySource of sourcesToTry) { + const excludeApiSources: string[] = [] + + // 跨平台时按歌名+歌手搜索替身;同源直接用原 songmid + let candidates: { music: any }[] + if (trySource === source) { + candidates = [{ music: musicInfo }] + } else { + const name = musicInfo?.name + const singer = musicInfo?.singer + if (!name) continue + const query = singer ? `${name} ${singer}` : name + let list: any[] = [] + try { + const searchRes: any = await (musicSdk as any)[trySource]?.musicSearch?.search?.(query, 1, 5) + list = searchRes?.list || [] + } catch (e: any) { + if (cfg['subsonic.enableDebug']) subsonicLog.debug(`[Subsonic] quality-select crossPlatform ${source}->${trySource} search failed: ${e?.message || e}`) + continue + } + if (list.length === 0) continue + // 没有任何一条通过歌名/歌手校验时不得回退 list[0], + // 否则会用不相干歌曲的 songmid 去解析播放地址,导致用户听到完全不同的歌。 + const match = list.find((it: any) => this.matchSongAcrossSources(it, musicInfo)) + if (!match) continue + const tSongmid = String(match?.songmid || match?.id || '') + if (!tSongmid) continue + candidates = [{ + music: { + source: trySource, + songmid: tSongmid, + id: `${trySource}_${tSongmid}`, + name: match.name, + singer: match.singer, + meta: { ...(match.meta || {}), songId: tSongmid }, + }, + }] + } + + const order = this.getQualityPriorityOrder(trySource, maxBitrate) + for (const cand of candidates) { + for (const q of order) { + try { + const r = await callUserApiGetMusicUrl( + trySource as any, cand.music as any, q, username, + undefined, autoSwitchCustom, + excludeApiSources.length ? excludeApiSources : undefined, + ) + if (r?.url) { + const selected = trySource === source + ? (q !== requestedQuality ? `quality:${source}/${q}` : undefined) + : `source:${source}->${trySource}/${q}` + return { url: r.url, quality: r.type || q, selected } + } + } catch (err: any) { + // 收集本次失败过的自定义源,避免后续音质/平台重复试死源 + const atts = err?.attempts + if (Array.isArray(atts)) { + for (const a of atts) { + if (a?.sourceId && !excludeApiSources.includes(a.sourceId)) excludeApiSources.push(a.sourceId) + if (a?.name && !excludeApiSources.includes(a.name)) excludeApiSources.push(a.name) + } + } + } + } + } + } + + throw new Error('Could not resolve music URL (all quality/source candidates failed)') + } + + /** 跨平台重锚时判断搜索结果是否匹配原曲:歌名归一化相等/包含 + 歌手部分匹配 */ + private matchSongAcrossSources(it: any, musicInfo: any): boolean { + const norm = (s: string) => String(s || '').toLowerCase().replace(/[\s\-_()()【】\[\]、,,。.]/g, '') + const n1 = norm(it?.name) + const n2 = norm(musicInfo?.name) + if (!n1 || !n2) return false + if (n1 !== n2 && !n1.includes(n2) && !n2.includes(n1)) return false + const s1 = norm(it?.singer) + const s2 = norm(musicInfo?.singer) + if (s1 && s2 && !s1.includes(s2) && !s2.includes(s1)) return false + return true + } + private async handleStream( req: http.IncomingMessage, res: http.ServerResponse, @@ -2930,15 +3895,15 @@ class SubsonicHandler { // [新增] 处理电台流: 随机取一首歌播放 if (id.startsWith('radio_tx_')) { const radioId = id.replace('radio_tx_', '') - // console.log(`[Subsonic] Radio stream requested: ${id}`) + // subsonicLog.debug(`[Subsonic] Radio stream requested: ${id}`) const songs = await fetchRadioSongs(radioId) - // console.log(`[Subsonic] Radio ${id} fetched ${songs?.length || 0} songs`) + // subsonicLog.debug(`[Subsonic] Radio ${id} fetched ${songs?.length || 0} songs`) if (songs && songs.length > 0) { // 随机取一首,提升电台体验 const s = songs[Math.floor(Math.random() * songs.length)] const songmid = s.mid || s.songmid - // console.log(`[Subsonic] Radio ${id} picked song: ${s.name || s.songname} (${songmid})`) + // subsonicLog.debug(`[Subsonic] Radio ${id} picked song: ${s.name || s.songname} (${songmid})`) const musicInfo: any = { source: 'tx', songmid, id: `tx_${songmid}`, meta: { songId: songmid } } const result = await callUserApiGetMusicUrl('tx', musicInfo, quality, username) @@ -2948,7 +3913,7 @@ class SubsonicHandler { const songKey = `tx_${songmid}_${quality}` const previous = this.subsonicActiveTasks.get(username) if (previous && previous.songKey !== songKey) { - console.log(`[Subsonic] User ${username} switched radio track, aborting previous background cache task: ${previous.songKey}`) + subsonicLog.debug(`[Subsonic] User ${username} switched radio track, aborting previous background cache task: ${previous.songKey}`) previous.controller.abort() } @@ -2961,7 +3926,7 @@ class SubsonicHandler { sourceName: 'tx', }).catch((err: any) => { if (err?.message !== 'Aborted') { - console.error('[Subsonic] radio cacheOnPlay failed:', err?.message || err) + subsonicLog.error('[Subsonic] radio cacheOnPlay failed:', err?.message || err) } }).finally(() => { if (this.subsonicActiveTasks.get(username)?.controller === controller) { @@ -2972,20 +3937,25 @@ class SubsonicHandler { res.writeHead(302, { Location: result.url }) return res.end() } else { - console.error(`[Subsonic] Radio ${id} failed to resolve music URL`) + subsonicLog.error(`[Subsonic] Radio ${id} failed to resolve music URL`) } } else { - console.warn(`[Subsonic] Radio ${id} returned empty song list`) + subsonicLog.warn(`[Subsonic] Radio ${id} returned empty song list`) } return this.sendError(res, 0, 'Could not resolve radio track', format) } - // [新增] 本地缓存优先播放:受 subsonic.playCacheFirst 开关控制(默认开启),若该歌曲已存在于用户的 cache 或 music 目录,直接流式回传本地文件,避免请求源站 + // [新增] 本地缓存优先播放:受 subsonic.playCacheFirst 开关控制(默认开启)。 + // 按音质优先级顺序(受 maxBitrate 上界约束)精确探测缓存,命中即直接回传本地文件,跳过源站回源与优先级优选; + // 与 resolveStreamUrl 共用 getQualityPriorityOrder,保证"缓存命中"与"回源优选"的音质语义一致(不串音质)。 if (global.lx.config['subsonic.playCacheFirst'] !== false) { - const cacheCheck = checkCache({ source, songmid, id, quality }, username, false) - if (cacheCheck.exists && cacheCheck.filename) { - console.log(`[Subsonic] Stream hit local cache for ${id} (${cacheCheck.quality || quality}): ${cacheCheck.filename} (${cacheCheck.folder})`) - return serveCacheFile(req, res, cacheCheck.filename, username) + const cacheOrder = this.getQualityPriorityOrder(source, maxBitrate) + for (const q of cacheOrder) { + const c = checkCache({ source, songmid, id, quality: q, exactQuality: true }, username, false) + if (c.exists && c.filename) { + subsonicLog.debug(`[Subsonic] Stream hit local cache for ${id} (${c.quality || q}): ${c.filename} (${c.folder})`) + return serveCacheFile(req, res, c.filename, username) + } } } @@ -3004,8 +3974,8 @@ class SubsonicHandler { } // [诊断] 打印回源结果与客户端的歌曲元数据参数,便于排查 kw 等源仍为 Unknown 的问题 if (global.lx.config['subsonic.enableDebug']) { - console.log(`[Subsonic] stream resolve ${id}: name=${musicInfo.name || '(空)'} singer=${musicInfo.singer || '(空)'} album=${musicInfo.meta?.albumName || '(空)'}`) - console.log(`[Subsonic] stream params: name=${params.get('name') || ''} title=${params.get('title') || ''} artist=${params.get('artist') || ''} album=${params.get('album') || ''}`) + subsonicLog.debug(`[Subsonic] stream resolve ${id}: name=${musicInfo.name || '(空)'} singer=${musicInfo.singer || '(空)'} album=${musicInfo.meta?.albumName || '(空)'}`) + subsonicLog.debug(`[Subsonic] stream params: name=${params.get('name') || ''} title=${params.get('title') || ''} artist=${params.get('artist') || ''} album=${params.get('album') || ''}`) } // [修复] 客户端(音流)通常在 stream 请求里附带真实元数据(name/artist/album), // 用于补充 Subsonic 自身不完整的歌曲信息(kw 等无 getMusicInfo 的源尤其依赖它) @@ -3026,7 +3996,7 @@ class SubsonicHandler { albumName: params.get('album') || musicInfo.meta?.albumName || '', }, } - console.log(`[Subsonic] stream ${id}: 已用客户端参数补全 name=${cName}`) + subsonicLog.debug(`[Subsonic] stream ${id}: 已用客户端参数补全 name=${cName}`) // [修复] 把客户端补全后的结果也落盘,否则重启后还得再靠客户端参数补全一次, // 一旦客户端未带参数就会再次缺失 this.cacheOnlineSong(musicInfo) @@ -3060,33 +4030,35 @@ class SubsonicHandler { } } - const result = await callUserApiGetMusicUrl(source as any, musicInfo as any, quality, username) + const result = await this.resolveStreamUrl(source, songmid, id, musicInfo, quality, maxBitrate, username) if (result && result.url) { // [诊断] 打印缓存触发决策,便于排查 Subsonic 播放不缓存问题 - console.log(`[Subsonic] stream cacheOnPlay: enabled=${global.lx.config['subsonic.cacheOnPlay']} user=${username} url=${String(result.url).slice(0, 90)}`) + if (global.lx.config['subsonic.enableDebug']) { + subsonicLog.debug(`[Subsonic] stream cacheOnPlay: enabled=${global.lx.config['subsonic.cacheOnPlay']} user=${username} url=${String(result.url).slice(0, 90)}${result.selected ? ' selected=' + result.selected : ''}`) + } // [新增] 播放时触发服务器缓存保存:受 subsonic.cacheOnPlay 开关控制 // 后台落盘到该用户缓存目录;已在播放的上一首若未下载完成,在切换新歌曲时自动 abort 中断,避免连切刷歌堆积带宽 if (global.lx.config['subsonic.cacheOnPlay'] && username) { - const songKey = `${source}_${songmid}_${quality}` + const songKey = `${source}_${songmid}_${result.quality}` const previous = this.subsonicActiveTasks.get(username) if (previous && previous.songKey !== songKey) { - console.log(`[Subsonic] User ${username} switched track, aborting previous background cache task: ${previous.songKey}`) + subsonicLog.debug(`[Subsonic] User ${username} switched track, aborting previous background cache task: ${previous.songKey}`) previous.controller.abort() } const controller = new AbortController() this.subsonicActiveTasks.set(username, { songKey, controller }) - void downloadAndCache(musicInfo, result.url, quality, username, controller.signal, false, true, true, { + void downloadAndCache(musicInfo, result.url, result.quality, username, controller.signal, false, true, true, { requestedSource: source, downloadSource: source, sourceName: source, }).then(() => { - console.log(`[Subsonic] cacheOnPlay: cache task done for ${musicInfo.id} (${quality})`) + subsonicLog.debug(`[Subsonic] cacheOnPlay: cache task done for ${musicInfo.id} (${quality})`) }).catch((err: any) => { if (err?.message !== 'Aborted') { - console.error('[Subsonic] cacheOnPlay failed:', err?.message || err) + subsonicLog.error('[Subsonic] cacheOnPlay failed:', err?.message || err) } }).finally(() => { if (this.subsonicActiveTasks.get(username)?.controller === controller) { @@ -3330,7 +4302,7 @@ class SubsonicHandler { res.writeHead(204) res.end() } catch (e: any) { - console.error('[Subsonic] handleGetCoverArt error:', e?.message || e) + subsonicLog.error('[Subsonic] handleGetCoverArt error:', e?.message || e) if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json' }) if (!res.writableEnded) res.end(JSON.stringify({ error: 'cover art error' })) } @@ -3374,7 +4346,7 @@ class SubsonicHandler { listId: `art_${source}_${artistEntry.id}` })) } catch (e) { - console.error(`[Subsonic] getTopSongs SDK error for ${artist || id}:`, e) + subsonicLog.error(`[Subsonic] getTopSongs SDK error for ${artist || id}:`, e) } } @@ -3396,6 +4368,11 @@ class SubsonicHandler { picked = all.slice(0, count) } + // [dislike] 热门歌曲同样过滤 + if (global.lx.config['subsonic.hideDisliked']) { + picked = await this.filterDislikedEntries(username, picked) + } + if (format === 'json') { return this.sendResponse(res, { topSongs: { @@ -3580,7 +4557,7 @@ class SubsonicHandler { hash = match.hash || match.meta?.hash || match.types?.[0]?.hash || '' } } catch (e) { - console.error('[Subsonic] Auto-resolve kg hash for lyric failed:', e) + subsonicLog.error('[Subsonic] Auto-resolve kg hash for lyric failed:', e) } } @@ -3649,7 +4626,7 @@ class SubsonicHandler { }, format) } catch (err: any) { - console.error(`[Subsonic] Lyric fetch error:`, err) + subsonicLog.error(`[Subsonic] Lyric fetch error:`, err) return this.sendError(res, 0, 'Failed to fetch lyrics: ' + err.message, format) } } diff --git a/src/server/userApi.ts b/src/server/userApi.ts index ebb53e8d..2eeb03e7 100644 --- a/src/server/userApi.ts +++ b/src/server/userApi.ts @@ -82,6 +82,7 @@ const loadedApis = new Map() // API 初始化状态追踪 map const apiStatus = new Map() +/** 返回已成功加载的 API 源数量,用于状态展示与健康检查。 */ export function getLoadedApisCount() { return loadedApis.size } diff --git a/src/server/utils/dislikeCache.ts b/src/server/utils/dislikeCache.ts new file mode 100644 index 00000000..0d1e4f5a --- /dev/null +++ b/src/server/utils/dislikeCache.ts @@ -0,0 +1,87 @@ +import type { DislikeRuleSet } from '@/modules/dislike/match' +import { parseDislikeRules } from '@/modules/dislike/match' + +/** + * dislike 规则集解析缓存。 + * + * 原实现里每次过滤入口都会读取并重新解析整条规则串(parseDislikeRules 会对每个 + * 歌手 / 歌名做 replaceAll + toLowerCase + 版本归一化),一次请求往往触发多次, + * 列表越大开销越明显。这里按用户缓存解析结果: + * - 写入时立即失效(invalidateDislikeCache),保证自己改的规则马上生效; + * - 并以 TTL 兜底外部(如 lx-music 客户端同步)对规则的变更。 + */ + +const cache = new Map() +const mutatedAt = new Map() +const TTL = 30 * 1000 + +/** 写入 dislike 规则后调用,使该用户的缓存立即失效 */ +export function invalidateDislikeCache(username: string): void { + mutatedAt.set(username, Date.now()) + cache.delete(username) +} + +/** + * 读取并(按需)解析某用户的 dislike 规则集。 + * @param readRules 读取原始规则串的函数(由各调用方封装其 userSpace 读取逻辑) + */ +export async function getCachedDislikeRuleSet( + username: string, + readRules: () => Promise, +): Promise { + const mutated = mutatedAt.get(username) ?? 0 + const cached = cache.get(username) + if (cached && cached.mutatedAt >= mutated && Date.now() - cached.ts < TTL) { + return cached.set + } + let set: DislikeRuleSet | null = null + try { + const rules = await readRules() + set = parseDislikeRules(String(rules || '')) + + // Merge disliked albums and artists from disk library files + try { + const { getUserDirname } = require('@/user') + const path = require('node:path') + const fs = require('node:fs') + const uDir = getUserDirname(username) + const dLibDir = path.join(global.lx.userPath, uDir, 'dislike', 'library') + const albumsFile = path.join(dLibDir, 'albums.json') + if (fs.existsSync(albumsFile)) { + const albumsArr = JSON.parse(fs.readFileSync(albumsFile, 'utf8')) + if (Array.isArray(albumsArr)) { + const { normalizeText, splitSingers } = require('@/modules/dislike/match') + for (const x of albumsArr) { + const albumName = normalizeText(String(x.name || '')) + if (!albumName) continue + let sSet = set.albums.get(albumName) + if (!sSet) { + sSet = new Set() + set.albums.set(albumName, sSet) + } + const singers = splitSingers(x.artistName) + for (const s of singers) sSet.add(s) + } + } + } + const artistsFile = path.join(dLibDir, 'artists.json') + if (fs.existsSync(artistsFile)) { + const artistsArr = JSON.parse(fs.readFileSync(artistsFile, 'utf8')) + if (Array.isArray(artistsArr)) { + const { normalizeText } = require('@/modules/dislike/match') + for (const x of artistsArr) { + const singerName = normalizeText(String(x.name || '')) + if (singerName) set.singerNames.add(singerName) + } + } + } + } catch (err: any) { + console.warn('[DislikeCache] Merge library failed:', err.message) + } + } catch (e) { + console.error('[Subsonic] 读取 dislike 规则失败:', e) + return cached?.set ?? null + } + cache.set(username, { ts: Date.now(), mutatedAt: mutated, set }) + return set +} diff --git a/src/server/utils/recommendAlbums.ts b/src/server/utils/recommendAlbums.ts index f6c37e16..61d8bcd1 100644 --- a/src/server/utils/recommendAlbums.ts +++ b/src/server/utils/recommendAlbums.ts @@ -12,23 +12,27 @@ export const fetchRecommendedAlbums = async (type: string, size: number = 20) => comm: { ct: 24, cv: 0 } } + // 每个地区的抓取量:按请求总量分摊到 6 个地区。 + // 原来固定写死 num=5/10(合计约 30 条),客户端滚动一页就见底; + // 现在按 size 动态分配,让推荐池能支撑多页翻页。 + const perArea = Math.max(5, Math.ceil((size || 20) / 6)) + if (type === 'recent') { - // [最新上架] 6个地区每个地区前5个组合 + // [最新上架] 6个地区每个地区前 perArea 个组合 for (let i = 1; i <= 6; i++) { payload[`area_${i}`] = { module: 'newalbum.NewAlbumServer', method: 'get_new_album_info', - param: { area: i, start: 0, num: 5 }, + param: { area: i, start: 0, num: perArea }, } } } else if (type === 'random') { - // [随机推荐] area 1-6 随机抽取和组合显示30条 - // 从每个地区多取一些(10个),合并后随机打乱 + // [随机推荐] area 1-6 随机抽取和组合 for (let i = 1; i <= 6; i++) { payload[`area_${i}`] = { module: 'newalbum.NewAlbumServer', method: 'get_new_album_info', - param: { area: i, start: 0, num: 10 }, + param: { area: i, start: 0, num: perArea }, } } } else { @@ -67,13 +71,13 @@ export const fetchRecommendedAlbums = async (type: string, size: number = 20) => } } - // 针对 random 类型进行打乱并截取 30 条 + // 针对 random 类型进行打乱,并按请求数量截取 + // (不再固定 30 条,否则客户端滚动一页就到底) if (type === 'random') { rawList.sort(() => Math.random() - 0.5) - rawList = rawList.slice(0, 30) + rawList = rawList.slice(0, size) } else if (type === 'recent') { - // recent 也限制在 30 条(5*6) - rawList = rawList.slice(0, 30) + rawList = rawList.slice(0, size) } return rawList.map(item => { diff --git a/src/server/utils/recommendSongs.ts b/src/server/utils/recommendSongs.ts new file mode 100644 index 00000000..808e068d --- /dev/null +++ b/src/server/utils/recommendSongs.ts @@ -0,0 +1,75 @@ +import musicSdkRaw from '@/modules/utils/musicSdk/index.js' +import { fetchRecommendedAlbums } from './recommendAlbums' + +const musicSdk = musicSdkRaw as any + +// 每日推荐歌曲缓存:按「自然日」刷新一次,当天内稳定(同一天多次请求共享结果), +// 次日自动换一批。QQ 抓取失败时回退到上次成功结果(即便已跨天),避免每日推荐空白。 +const recommendSongsCache = new Map() +const ALBUM_POOL = 8 // 每天从推荐专辑里取前 N 张,汇聚其歌曲 + +/** + * 获取每日推荐歌曲(歌曲级,区别于 recommendAlbums 的专辑级)。 + * 实现思路:复用已验证的推荐专辑接口(fetchRecommendedAlbums)拿到一批推荐专辑, + * 再逐张调用 tx 平台的 getAlbumSongs 取出专辑内歌曲,去重、按日期做种子洗牌后返回。 + * 全部走公开接口,无需登录。 + * @param size 返回数量,默认 20,最大 100 + */ +export const fetchRecommendedSongs = async (size = 20): Promise => { + const safeSize = Math.min(Math.max(parseInt(String(size)) || 20, 1), 100) + const dateKey = new Date().toISOString().slice(0, 10) // YYYY-MM-DD + + const cached = recommendSongsCache.get('daily') + if (cached && cached.date === dateKey && cached.songs.length > 0) { + return cached.songs.slice(0, safeSize) + } + + try { + const albums = await fetchRecommendedAlbums('random', 30) + const mids = albums + .map((a: any) => String(a.id || '').replace('alb_tx_', '')) + .filter(Boolean) + .slice(0, ALBUM_POOL) + + const songMap = new Map() + await Promise.all(mids.map(async (mid: string) => { + try { + const sdk = musicSdk?.tx?.extendDetail + if (!sdk?.getAlbumSongs) return + const data = await sdk.getAlbumSongs(mid) + for (const s of data?.list || []) { + const songmid = s.songmid || s.mid + if (!songmid) continue + const id = `tx_${songmid}` + if (songMap.has(id)) continue + // filterMusicInfoItem 已是标准 LX.Music.MusicInfo,仅补齐 id(供 Subsonic 客户端定位/播放) + songMap.set(id, { ...s, id, source: 'tx' }) + } + } catch (e) { + console.error(`[RecommendSongs] 专辑 ${mid} 取歌失败:`, e) + } + })) + + let all = Array.from(songMap.values()) + + // 按日期做种子的稳定洗牌:同一天结果一致,跨天自然变化 + let seed = 0 + for (const ch of dateKey) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0 + const rand = () => { + seed = (seed * 1664525 + 1013904223) >>> 0 + return seed / 0xffffffff + } + for (let i = all.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)) + ;[all[i], all[j]] = [all[j], all[i]] + } + + recommendSongsCache.set('daily', { date: dateKey, songs: all }) + return all.slice(0, safeSize) + } catch (e) { + console.error('[RecommendSongs] 获取每日推荐歌曲出错:', e) + // 抓取失败:回退到过期缓存(哪怕已跨天),避免每日推荐直接空白 + if (cached && cached.songs.length > 0) return cached.songs.slice(0, safeSize) + return [] + } +} diff --git a/src/server/utils/songVersion.ts b/src/server/utils/songVersion.ts new file mode 100644 index 00000000..426006f6 --- /dev/null +++ b/src/server/utils/songVersion.ts @@ -0,0 +1,46 @@ +import { SPLIT_CHAR } from '@/constants' + +/** + * 歌名 / 歌手归一化(dislike 匹配、排序、折叠共用)。 + * + * 这些函数在 filterDisliked 里会被「每首歌 × 每歌手 × 每条规则」反复调用, + * 因此结果在此处集中缓存,避免重复 replaceAll + toLowerCase 的开销。 + */ + +const normalizeCache = new Map() +const NORMALIZE_CACHE_LIMIT = 5000 + +/** 带容量上限的归一化结果缓存,避免 filterDisliked 中「每首歌 × 每歌手 × 每条规则」重复计算。 */ +const cachedNormalize = (key: string, compute: () => string): string => { + const hit = normalizeCache.get(key) + if (hit !== undefined) return hit + const out = compute() + if (normalizeCache.size > NORMALIZE_CACHE_LIMIT) normalizeCache.clear() + normalizeCache.set(key, out) + return out +} + +/** 归一化:与 filterRules 保持一致(@ → #、去空格、小写) */ +export const normalizeText = (value: unknown): string => + cachedNormalize(`t:${value ?? ''}`, () => + String(value ?? '') + .replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS) + .trim() + .toLowerCase() + ) + +/** 歌名版本后缀正则(Live / Remix / 现场 / 伴奏 …),供排序、折叠、归一化共用。 + * 支持两种形式:括号内「晴天 (Live)」「晴天(现场版)」与无括号连字符「晴天 - Remix」「晴天 - Live」 */ +export const VERSION_SUFFIX_RE = + /(?:[\s\-–—_]*[((](?:live|remix|现场|伴奏|纯音乐|demo|翻唱|acoustic|instrumental|off\s*vocal|版)[^))]*[))]|[\s]*[-–—]\s*(?:live|remix|现场|伴奏|纯音乐|demo|翻唱|acoustic|instrumental|off\s*vocal))\s*$/i + +/** + * 归一化歌名:剥离常见版本后缀。 + * 「晴天 (Live)」「晴天 - Remix」「晴天(现场版)」→「晴天」, + * 提升 dislike / 排序 / 折叠的跨版本召回。 + */ +export const normalizeSongName = (value: unknown): string => + cachedNormalize(`s:${value ?? ''}`, () => { + const base = normalizeText(value) + return base.replace(VERSION_SUFFIX_RE, '').trim() + }) diff --git a/src/types/config.d.ts b/src/types/config.d.ts index 6cd64ac8..dac91f00 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -265,6 +265,77 @@ declare namespace LX { */ 'subsonic.leaderboardSource'?: string + /** + * Subsonic 评分联动 dislike 的阈值 (默认 1) + * 评分 rating 满足 0 < rating <= dislikeRating 时视为「不喜欢」,写回 lx-music 原生 dislike 规则。 + * 设为 0 可关闭整条联动(评分仅作评分,不影响 dislike)。 + */ + 'subsonic.dislikeRating'?: number + + /** + * 解耦开关(正向): 评星 -> 不喜欢 是否自动联动 (默认 false) + * true 时, Subsonic 客户端打低星(0 < rating <= dislikeRating)会写回 lx-music 原生 dislike 规则; + * false 时, 评星仅记录到该用户 ratings 映射, 不触碰 dislike 规则, 与「不喜欢」解耦。 + */ + 'subsonic.linkRatingToDislike'?: boolean + + /** + * 解耦开关(反向): 不喜欢 -> 评星 是否自动联动 (默认 false) + * true 时, 网页端 / Subsonic 给某歌点「不喜欢」会回写该用户 subsonic-meta.json 的 ratings 映射(低星), + * 使 Subsonic 客户端能看到这颗星; false 时, 「不喜欢」仅写入原生 dislike 规则, 不回写评星, 与评星解耦。 + */ + 'subsonic.linkDislikeToRating'?: boolean + + /** + * Subsonic 列表中是否隐藏 dislike 命中的歌曲 (默认 true) + * 开启后,命中不喜欢规则的歌曲会直接从专辑/歌单/搜索等列表中剔除; + * 关闭则保留,仅以评分形式体现(不做剔除)。 + */ + 'subsonic.hideDisliked'?: boolean + + /** + * dislike 是否跨平台同名命中 (默认 false) + * 各平台歌手 / 专辑 ID 互不相通且无映射表, + * 开启后同名不同歌手、同名不同专辑会被一并屏蔽(有误伤风险)。 + */ + 'subsonic.dislikeCrossSource'?: boolean + + /** + * 推荐类接口是否排除 dislike 命中的歌曲 (默认 true) + * 与 hideDisliked 相互独立: + * - hideDisliked=false 时歌曲仍在歌单/搜索里可见 + * - dislikeNoRecommend=true 时它不会出现在每日推荐 / 随机 / 相似歌曲中 + */ + 'subsonic.dislikeNoRecommend'?: boolean + + /** + * 多歌手匹配模式(合唱歌曲与专辑维度共用)(默认 any) + * - any 任一位歌手命中即屏蔽(合辑友好) + * - all 所有歌手都命中才屏蔽(最保守,不牵连合作者) + * - primary 只看第一位歌手(主唱) + */ + 'subsonic.dislikeDuetMode'?: 'any' | 'all' | 'primary' + + /** + * dislike 歌名是否做「去版本后缀」归一化 (默认 true) + * 开启后「晴天 (Live)」「晴天 - Remix」也能命中「晴天」规则,提升召回。 + * 代价:极少数两首不同的歌只差后缀时会被视为同名。 + */ + 'subsonic.dislikeNormalizeName'?: boolean + + /** + * dislike 歌曲 / 专辑级别是否都要求歌手同时匹配 (默认 true) + * 开启后,只记歌名、没记歌手的规则不会单独命中, + * 避免不同歌手的同名歌曲 / 同名专辑被一起屏蔽。 + */ + 'subsonic.dislikeRequireSinger'?: boolean + + /** + * Subsonic 推荐池容量 (默认 100) + * 专辑列表可翻页数 ≈ 该容量 / 客户端请求的 size。 + */ + 'subsonic.recommendPoolSize'?: number + /** * Subsonic 在线搜索默认平台 (如 wy,tx,kw,kg,mg) */ @@ -288,6 +359,41 @@ declare namespace LX { */ 'subsonic.playCacheFirst'?: boolean + /** + * Subsonic 音质优选总开关 (默认 true)。关闭后 stream 仅做单次解析、不做优先级选择。 + */ + 'subsonic.quality.enabled'?: boolean + + /** + * 音质优先级(从高到低, 逗号分隔), 默认 'flac,320k,128k'。按此顺序主动优选可用音质。 + */ + 'subsonic.quality.priority'?: string + + /** + * 逐源音质优先级覆盖, 如 'subsonic.quality.sources.kw.priority': ['flac','320k']。存在时优先于全局 priority。 + */ + 'subsonic.quality.sources'?: Record + + /** + * 客户端 maxBitrate 上界模式: 'hard' 只选 ≤ 上限的最高优先级音质; 'soft' 上限内都取不到时再突破上限选更高优先级。 + */ + 'subsonic.quality.clientCapMode'?: 'hard' | 'soft' + + /** + * 跨平台优选顺序(逗号分隔), 默认 'kw,tx,wy,mg,kg'。客户端所选源始终优先, 其余按此顺序优选。 + */ + 'subsonic.source.priority'?: string + + /** + * 是否允许跨平台优选 (默认 true)。关闭则只在客户端所选源内做音质优选。 + */ + 'subsonic.source.crossPlatform'?: boolean + + /** + * 同源是否切换其它自定义源脚本 (默认 true)。callUserApiGetMusicUrl 内部已循环同平台候选脚本。 + */ + 'subsonic.source.autoSwitchCustom'?: boolean + /** * 歌手信息源优先级 */ diff --git a/src/types/dislike_list.d.ts b/src/types/dislike_list.d.ts index 153b8338..ca700104 100644 --- a/src/types/dislike_list.d.ts +++ b/src/types/dislike_list.d.ts @@ -23,15 +23,40 @@ declare namespace LX { singer: string } - type DislikeRules = string + /** + * 专辑维度(lxserver 扩展) + * 规则串中以 !<专辑名>@<歌手> 形式存储,按歌手拆成多条。 + */ + interface DislikeAlbumInfo { + albumName: string + singer: string + } + + interface DislikeSongInfo { + id?: string + name: string + singer: string + source?: string + pic?: string + interval?: string + meta?: any + dislikeRule?: string + } + + interface DislikeListData { + dislikeList: DislikeSongInfo[] + } + + type DislikeRules = DislikeListData interface DislikeInfo { // musicIds: Set names: Set musicNames: Set singerNames: Set + // albumKeys: Set // list: LX.Dislike.ListItem[] - rules: DislikeRules + rules: string // The raw string representation for matching } } } diff --git a/src/utils/log4js.ts b/src/utils/log4js.ts index 420ef893..db1f58bc 100644 --- a/src/utils/log4js.ts +++ b/src/utils/log4js.ts @@ -48,12 +48,20 @@ const createLogConfig = (logPath: string) => { keepFileExt: true, numBackups: 10, }, + subsonic: { + type: 'file', + filename: path.join(logPath, 'subsonic.log'), + maxLogSize: 10485760, + backups: 5, + keepFileExt: true, + }, }, categories: { default: { appenders: ['app', 'errors', 'console'], level: 'DEBUG' }, access: { appenders: ['access'], level: 'ALL' }, login: { appenders: ['login'], level: 'ALL' }, token: { appenders: ['token'], level: 'ALL' }, + subsonic: { appenders: ['subsonic', 'errors'], level: 'DEBUG' }, }, } } @@ -69,3 +77,4 @@ export const syncLog = log4js.getLogger('sync') export const accessLog = log4js.getLogger('access') export const loginLog = log4js.getLogger('login') export const tokenLog = log4js.getLogger('token') +export const subsonicLog = log4js.getLogger('subsonic') diff --git a/tsconfig.json b/tsconfig.json index 24a6acda..3ae0ad95 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,6 @@ } }, "compilerOptions": { - "ignoreDeprecations": "6.0", "target": "ES2022", "lib": [ "ES2022"