feat: sync external playlist#194
Conversation
SafeDep Report SummaryNo dependency changes detected. Nothing to scan. This report is generated by SafeDep Github App |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedThe pull request is closed. 概述该 PR 实现了从网易云音乐和 QQ 音乐导入歌单的功能,并支持自动匹配到 B 站视频。同时添加了酷狗音乐作为歌词来源,并删除了大量 React Native 最佳实践文档。 变更
序列图sequenceDiagram
participant User as 用户
participant UI as 外部歌单 UI
participant Service as ExternalPlaylistService
participant API as 网易/QQ API
participant BilibiliAPI as Bilibili API
participant DB as 本地数据库
User->>UI: 输入歌单链接
UI->>Service: fetchExternalPlaylist(id, source)
Service->>API: 获取歌单元数据
API-->>Service: 返回歌曲列表
Service-->>UI: 显示歌曲列表
User->>UI: 开始匹配
UI->>Service: matchExternalPlaylist(tracks, onProgress)
loop 每首歌曲
Service->>BilibiliAPI: 搜索视频
BilibiliAPI-->>Service: 返回候选视频
Service->>Service: 评分并选择最佳匹配
Service-->>UI: 更新进度和结果
end
User->>UI: 手动调整或保存
UI->>DB: 保存匹配结果到本地歌单
DB-->>UI: 保存完成
UI-->>User: 导航回歌单列表
预计代码审查工作量🎯 4 (复杂) | ⏱️ ~60 分钟 可能相关的 PR
诗句
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🤖 Fix all issues with AI agents
In `@packages/external-playlist-sync/meting.d.ts`:
- Line 1: The ambient module declaration is too minimal and causes the Meting
class and its methods to be typed as any; update the declaration for module
'@roitium/meting' to export a minimal set of types so static analysis can
type-check usage: declare and export a Meting class (symbol: Meting) with a
typed constructor (e.g., constructor(options?: Record<string, any>)), and add
method signatures used by the codebase such as init(): Promise<void>,
search(query: string, source?: string): Promise<any>, getSong(id: string):
Promise<any>, getPlaylist(id: string): Promise<any> (and any other methods your
index.ts calls), plus export the class as the module default or named export to
match how it is imported; include simple interfaces/types for option objects if
referenced so the compiler no longer infers any.
In `@packages/external-playlist-sync/package.json`:
- Around line 10-12: The package `@roitium/meting` is currently listed under
devDependencies but is imported at runtime in index.ts (import of
`@roitium/meting` at top and use around line 8); move "@roitium/meting" from
devDependencies into the dependencies object in package.json so it is installed
in production builds (ensure the exact package key "@roitium/meting" is added
under "dependencies" and removed from "devDependencies").
In `@packages/meting/src/meting.js`:
- Around line 77-126: The current retry/timeout logic reuses a single
AbortController and timeoutId across attempts and clears the timeout before
response.text() finishes, so retries immediately fail and response.text()
remains unprotected; fix by moving creation of AbortController, timeoutId and
assignment requestOptions.signal inside each attempt (inside makeRequest) so
each retry gets a fresh controller and timeout, start the per-attempt setTimeout
that aborts that controller, await both fetch and response.text() before
clearing that attempt's timeout (so response.text() is also covered), and ensure
you set requestOptions.signal = controller.signal for that attempt and
clearTimeout(timeoutId) in both the success and error paths for that attempt to
avoid leaks; reference symbols: AbortController, controller, timeoutId,
requestOptions.signal, makeRequest, fetch, response.text(), retries.
In `@packages/meting/src/providers/tencent.js`:
- Around line 254-266: Before accessing data.data[0] inside the qualityMap loop,
add a guard to verify the API response contains an array with at least one
element (e.g., check data && Array.isArray(data.data) && data.data.length > 0)
and handle the empty case (return null/undefined or skip processing) to avoid
TypeError; update the loop using the existing symbols (qualityMap, vkeys,
response.req_0, vkeys[i].purl, and the accesses to .file and .mid) so you only
read data.data[0].file and data.data[0].mid after the presence check and provide
a clear fallback path.
🟡 Minor comments (22)
apps/mobile/CHANGELOG.md-10-13 (1)
10-13:⚠️ Potential issue | 🟡 MinorCHANGELOG 条目与 PR 主要功能不一致。
当前 CHANGELOG 仅记录了"支持 Kuwo、Kugou、Baidu 音乐的歌词搜索",但根据 PR 标题("feat: sync external playlist")和关联的 issue
#128,本 PR 的核心功能是导入/同步外部平台歌单。建议补充一条更能体现主要功能的条目。📝 建议的 CHANGELOG 条目补充
### Added +- 支持导入外部音乐平台歌单(Kuwo、Kugou、QQ 音乐、网易云音乐等) - 支持 Kuwo、Kugou、Baidu 音乐的歌词搜索apps/mobile/src/lib/utils/playlistUrlParser.ts-12-18 (1)
12-18:⚠️ Potential issue | 🟡 MinorQQ 域名匹配过于宽泛。
.qq.com会匹配所有 QQ 相关域名(如 news.qq.com、mail.qq.com),而 QQ 音乐的实际域名是y.qq.com。建议使用更精确的匹配。建议修复
// QQ Music -if (text.includes('.qq.com')) { +if (text.includes('y.qq.com')) { const result = /id=(\d+)/.exec(text) if (result?.[1]) { return { id: result[1], source: 'qq' } } }packages/meting/CHANGELOG.md-47-47 (1)
47-47:⚠️ Potential issue | 🟡 Minor版本日期包含占位符。
2025-01-XX应该替换为实际的发布日期。建议修复
-## [1.5.14] - 2025-01-XX +## [1.5.14] - 2025-01-15packages/meting/package.json-61-66 (1)
61-66:⚠️ Potential issue | 🟡 Minor更新过期的开发依赖版本。
@rollup/plugin-babel应更新至 6.1.0(当前 6.0.4),@rollup/plugin-node-resolve 应更新至 16.0.3(当前 16.0.1),rollup 应更新至 4.57.1(当前 4.49.0)。@rollup/plugin-terser 已是最新版本。当前无已知安全漏洞。packages/meting/src/providers/tencent.js-197-198 (1)
197-198:⚠️ Potential issue | 🟡 Minor
JSON.parse缺少错误处理。如果响应格式异常,
JSON.parse(result)会抛出异常。建议添加 try-catch 或在调用前验证数据格式。apps/mobile/src/utils/time.ts-38-46 (1)
38-46:⚠️ Potential issue | 🟡 Minor建议对非数字输入添加 NaN 校验。
当
durationStr包含非数字字符(如"ab:cd")时,Number()会返回NaN,导致后续计算结果也为NaN,而非预期的0。🛡️ 建议的修复方案
export function parseDurationString(durationStr: string): number { - const parts = durationStr.split(':').map(Number) + const parts = durationStr.split(':').map(Number) + if (parts.some(isNaN)) return 0 if (parts.length === 3) { return parts[0] * 3600 + parts[1] * 60 + parts[2] } else if (parts.length === 2) { return parts[0] * 60 + parts[1] } return 0 }apps/mobile/src/lib/facades/syncExternalPlaylist.ts-119-127 (1)
119-127:⚠️ Potential issue | 🟡 Minor静默跳过失败的 key 生成可能导致数据丢失
当
generateUniqueTrackKey(payload)返回错误时,代码会静默跳过该曲目,不会有任何日志或警告。这可能导致用户保存的歌单中缺少某些曲目,但用户无法得知原因。建议在跳过时至少记录一条警告日志。
🔧 建议的修改
const orderedTrackIds: number[] = [] for (const payload of trackPayloads) { const keyResult = generateUniqueTrackKey(payload) if (keyResult.isOk()) { const id = trackIdsMap.get(keyResult.value) if (id) { orderedTrackIds.push(id) + } else { + logger.warn('Track ID not found in map', { key: keyResult.value }) } + } else { + logger.warn('Failed to generate unique track key', { payload, error: keyResult.error }) } }apps/mobile/src/lib/api/kugou/api.ts-73-79 (1)
73-79:⚠️ Potential issue | 🟡 Minor缺少 HTTP 错误检查和请求头
这个 fetch 调用缺少两个在
search()方法中存在的处理:
- 没有检查
res.ok状态- 没有使用
this.getHeaders()设置请求头如果 Kugou 歌词 API 对请求头有同样的要求,可能会导致请求失败。
🔧 建议的修改
return ResultAsync.fromPromise( - fetch(searchUrl).then( - (res) => res.json() as Promise<KugouLyricSearchResponse>, - ), + fetch(searchUrl, { headers: this.getHeaders() }).then((res) => { + if (!res.ok) { + throw new Error(`Kugou lyric search error: ${res.statusText}`) + } + return res.json() as Promise<KugouLyricSearchResponse> + }), (e) => new Error('Failed to search lyric candidate on Kugou', { cause: e }), ).andThen((searchRes) => {apps/mobile/src/lib/api/kugou/api.ts-97-101 (1)
97-101:⚠️ Potential issue | 🟡 Minor歌词下载请求同样缺少错误检查和请求头
与上面的歌词搜索请求一样,这个 fetch 调用也缺少 HTTP 错误检查和请求头。
🔧 建议的修改
return ResultAsync.fromPromise( - fetch(downloadUrl).then( - (res) => res.json() as Promise<KugouLyricDownloadResponse>, - ), + fetch(downloadUrl, { headers: this.getHeaders() }).then((res) => { + if (!res.ok) { + throw new Error(`Kugou lyric download error: ${res.statusText}`) + } + return res.json() as Promise<KugouLyricDownloadResponse> + }), (e) => new Error('Failed to download lyric from Kugou', { cause: e }), ).map((downloadRes) => {packages/external-playlist-sync/index.ts-31-31 (1)
31-31:⚠️ Potential issue | 🟡 Minor顶层 Promise 未处理,可能导致未捕获的拒绝错误。
静态分析正确标记了此问题。当
main()内部抛出异常时(如 try-catch 之外的错误),会导致 unhandled promise rejection。🐛 建议修复
-main() +main().catch((error) => { + console.error('Fatal error:', error) + process.exitCode = 1 +})或使用
void操作符显式标记忽略:-main() +void main()apps/mobile/src/hooks/stores/useExternalPlaylistSyncStore.tsx-26-26 (1)
26-26:⚠️ Potential issue | 🟡 Minor
setProgress可能产生除零错误。当
total为 0 时,current / total会返回Infinity或NaN,可能导致 UI 显示异常。🛡️ 建议修改
- setProgress: (current, total) => set({ progress: current / total }), + setProgress: (current, total) => set({ progress: total > 0 ? current / total : 0 }),packages/external-playlist-sync/matcher.ts-32-41 (1)
32-41:⚠️ Potential issue | 🟡 Minor
parseDuration缺少对无效输入的防护。当解析包含非数字字符的时长字符串时,
Number()会返回NaN,导致后续计算出现问题。建议添加校验。🛡️ 建议修改
function parseDuration(durationStr: string): number { const parts = durationStr.split(':').map(Number) + if (parts.some(isNaN)) { + return 0 + } if (parts.length === 3) { return parts[0] * 3600 + parts[1] * 60 + parts[2] } else if (parts.length === 2) { return parts[0] * 60 + parts[1] } return 0 }packages/external-playlist-sync/matcher.ts-17-23 (1)
17-23:⚠️ Potential issue | 🟡 Minor修复静态分析警告:异步方法缺少
await表达式。静态分析工具报告
search方法是async但没有await表达式。对于模拟实现,应该移除async关键字或添加await。🔧 建议修改
// 模拟 B站 API const bilibiliAPI: BilibiliAPI = { - search: async (query: string, page: number) => { + search: async (query: string, _page: number) => { + await Promise.resolve() // Placeholder for actual async operation console.log(`[Mock] Searching for: ${query}, page: ${page}`) return [] }, }或者移除
async:const bilibiliAPI: BilibiliAPI = { - search: async (query: string, page: number) => { + search: (query: string, page: number): Promise<BilibiliSearchVideo[]> => { console.log(`[Mock] Searching for: ${query}, page: ${page}`) - return [] + return Promise.resolve([]) }, }packages/meting/src/providers/kuwo.js-15-23 (1)
15-23:⚠️ Potential issue | 🟡 Minor硬编码的 Cookie 和 CSRF Token 可能导致请求失败。
Headers 中包含硬编码的 Cookie 和 CSRF token (
kw_token=3E7JFQ7MRPL)。这些值可能会过期或被服务端拒绝,导致 API 请求失败。建议考虑动态获取这些凭证或在请求失败时提供明确的错误提示。packages/meting/src/providers/kugou.js-264-291 (1)
264-291:⚠️ Potential issue | 🟡 Minor歌词解码缺少对
response.content的空值检查。如果 API 返回的响应中
content字段不存在或为空,Buffer.from(response.content, 'base64')会抛出错误。🛡️ 建议添加空值检查
const response = JSON.parse(await this.meting._exec(api)); + if (!response.content) { + return JSON.stringify({ lyric: '', tlyric: '' }); + } const lyricData = { lyric: Buffer.from(response.content, 'base64').toString(), tlyric: '' };apps/mobile/src/components/modals/playlist/ManualMatchExternalSync.tsx-29-30 (1)
29-30:⚠️ Potential issue | 🟡 Minor在渲染函数中抛出错误可能导致应用崩溃。
当
extraData不存在时直接抛出错误会导致整个列表渲染失败。建议返回null并记录警告日志,而非抛出异常。🛡️ 建议改为安全处理
const renderItem = ({ item, extraData, }: ListRenderItemInfoWithExtraData<...>) => { - if (!extraData) throw new Error('Extradata 不存在') + if (!extraData) { + console.warn('ManualMatchExternalSync: extraData is missing') + return null + } return (packages/meting/src/providers/netease.js-239-262 (1)
239-262:⚠️ Potential issue | 🟡 MinorURL 解码缺少空值检查,可能导致运行时错误。
当
data.data为空数组或data.data[0]不存在时,访问data.data[0].uf或data.data[0].url会抛出 TypeError。🛡️ 建议添加防御性检查
urlDecode(result) { const data = JSON.parse(result); let url; - - if (data.data[0].uf && data.data[0].uf.url) { - data.data[0].url = data.data[0].uf.url; - } - - if (data.data[0].url) { + + const item = data.data?.[0]; + if (!item) { + return JSON.stringify({ url: '', size: 0, br: -1 }); + } + + if (item.uf?.url) { + item.url = item.uf.url; + } + + if (item.url) { url = { - url: data.data[0].url, - size: data.data[0].size, - br: data.data[0].br / 1000 + url: item.url, + size: item.size, + br: item.br / 1000 }; } else {packages/meting/src/providers/baidu.js-231-255 (1)
231-255:⚠️ Potential issue | 🟡 Minor
urlDecode缺少空值检查,可能导致运行时错误。如果 API 返回的数据中
songurl或songurl.url不存在,forEach调用会抛出 TypeError。🛡️ 建议添加防御性检查
urlDecode(result) { const data = JSON.parse(result); let maxBr = 0; let url; + + const urls = data.songurl?.url ?? []; - data.songurl.url.forEach(item => { + urls.forEach(item => { if (item.file_bitrate <= this.meting.temp.br && item.file_bitrate > maxBr) { maxBr = item.file_bitrate; url = { url: item.file_link, br: item.file_bitrate }; } });apps/mobile/src/app/playlist/external-sync.tsx-313-323 (1)
313-323:⚠️ Potential issue | 🟡 Minor同步重启逻辑存在潜在问题。
当
startIndex >= data.tracks.length时调用reset(),但由于 React 状态更新是异步的,下面计算effectiveStartIndex时results可能仍是旧值,导致effectiveStartIndex不会变为 0。建议简化此逻辑或显式传递起始索引。
🛡️ 建议修复逻辑
- const startIndex = Object.keys(results).length - if (startIndex >= data.tracks.length) { - if (startIndex === data.tracks.length) { - reset() - } - } - - const effectiveStartIndex = - Object.keys(results).length === data.tracks.length - ? 0 - : Object.keys(results).length + const currentResultCount = Object.keys(results).length + const isCompleted = currentResultCount >= data.tracks.length + + if (isCompleted) { + reset() + } + + const effectiveStartIndex = isCompleted ? 0 : currentResultCountpackages/meting/src/providers/kugou.js-217-248 (1)
217-248:⚠️ Potential issue | 🟡 Minor
urlDecode中缺少错误处理且存在潜在空引用。
- 访问
this.meting.temp.br时未检查this.meting.temp是否存在- 循环中的
this.meting._exec(api)调用未包含 try-catch,单个请求失败可能导致整个方法失败- 如果
data.data[0].relate_goods不存在,for...of 循环会抛出 TypeError🛡️ 建议添加防御性检查
async urlDecode(result) { const data = JSON.parse(result); let maxBr = 0; let url; + + const targetBr = this.meting?.temp?.br ?? 320; + const relateGoods = data.data?.[0]?.relate_goods ?? []; - for (const item of data.data[0].relate_goods) { - if (item.info.bitrate <= this.meting.temp.br && item.info.bitrate > maxBr) { + for (const item of relateGoods) { + if (item.info.bitrate <= targetBr && item.info.bitrate > maxBr) { const api = { // ... }; - const response = JSON.parse(await this.meting._exec(api)); - if (response.url) { + try { + const response = JSON.parse(await this.meting._exec(api)); + if (response.url) { + // ... + } + } catch (e) { + // Continue to next candidate on failure + continue; + }apps/mobile/src/components/modals/playlist/ManualMatchExternalSync.tsx-65-70 (1)
65-70:⚠️ Potential issue | 🟡 Minor视频时长解析假设固定的
MM:SS格式,可能导致运行时错误。当前实现假设
item.duration格式为MM:SS,但如果格式为HH:MM:SS或其他格式,parseInt会返回NaN,导致显示异常。🛡️ 建议添加格式检查
+const parseDuration = (duration: string): number => { + const parts = duration.split(':').map(Number) + if (parts.length === 2) { + return parts[0] * 60 + parts[1] + } + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2] + } + return 0 +} <Text variant='bodySmall' numberOfLines={1}> {item.author} -{' '} - {formatDurationToHHMMSS( - Math.round( - parseInt(item.duration.split(':')[0]) * 60 + - parseInt(item.duration.split(':')[1]), - ), - )} + {formatDurationToHHMMSS(parseDuration(item.duration))} </Text>packages/meting/src/providers/base.js-219-223 (1)
219-223:⚠️ Potential issue | 🟡 Minor
format方法检查逻辑存在问题
typeof this.format === 'function'始终为true,因为基类已定义format方法。但如果子类未覆盖该方法,调用时仍会抛出Error。建议改用标志位或检测是否为基类实现:
🐛 建议修复
// 使用当前 provider 的格式化方法 - if (typeof this.format === 'function') { + // 检查子类是否覆盖了 format 方法 + if (this.format !== BaseProvider.prototype.format) { const result = data.map(item => this.format(item)); return JSON.stringify(result); }
🧹 Nitpick comments (25)
apps/mobile/src/features/playlist/remote/components/PlaylistHeader.tsx (2)
47-47: 建议直接解构新增的 props,而非使用 rest 语法。当前使用
...props捕获剩余属性,然后通过props.disableMainButton、props.secondaryButtonText等方式访问,这种模式不够直观。既然这些属性已在接口中明确定义,建议直接解构以保持代码风格一致。♻️ 建议的修改
export const PlaylistHeader = memo(function PlaylistHeader({ coverUri, title, subtitles, description, onClickMainButton, mainButtonIcon, mainButtonText, linkedPlaylistId, id, - ...props + disableMainButton, + secondaryButtonText, + secondaryButtonIcon, + onClickSecondaryButton, + disableSecondaryButton, }: PlaylistHeaderProps) {然后将后续代码中的
props.xxx替换为直接引用:
props.disableMainButton→disableMainButtonprops.secondaryButtonText→secondaryButtonTextprops.secondaryButtonIcon→secondaryButtonIconprops.onClickSecondaryButton→onClickSecondaryButtonprops.disableSecondaryButton→disableSecondaryButton
109-109: 内联样式会在每次渲染时创建新对象。
style={{ marginLeft: 8 }}在每次渲染时都会创建新的对象引用,虽然影响较小,但建议将其移至StyleSheet中以保持一致性并避免不必要的重渲染。♻️ 建议的修改
在
styles对象中添加:const styles = StyleSheet.create({ // ...existing styles... + secondaryButton: { + marginLeft: 8, + }, })然后修改按钮样式引用:
<Button mode='outlined' icon={props.secondaryButtonIcon} onPress={props.onClickSecondaryButton} - style={{ marginLeft: 8 }} + style={styles.secondaryButton} disabled={props.disableSecondaryButton} >packages/meting/ARCHITECTURE.md (1)
9-21: 建议为代码块添加语言标识符。目录结构代码块缺少语言标识符,可以添加
text或plaintext以符合 markdown lint 规范。建议修复
-``` +```text src/ ├── meting.js # 主入口文件(重构后)packages/meting/CLAUDE.md (1)
73-75: 建议为流程图代码块添加语言标识符。可以使用
text或plaintext标识符。建议修复
-``` +```text 用户 API 调用 → 主 Meting 类 → Provider.executeRequest() → 平台特定处理 → 返回标准化结果 ```packages/meting/src/providers/tencent.js (1)
317-319: JSONP 解析使用魔法数字,易出错。
result.substring(18, result.length - 1)假设响应格式为固定的 JSONP 包装。如果腾讯 API 变更包装格式,此处会静默失败。建议使用更健壮的正则提取 JSON 内容。♻️ 建议的改进方案
lyricDecode(result) { - const jsonStr = result.substring(18, result.length - 1); + // 提取 JSONP 回调中的 JSON 内容 + const match = result.match(/\((\{.*\})\)$/s); + if (!match) { + return JSON.stringify({ lyric: '', tlyric: '' }); + } + const jsonStr = match[1]; const data = JSON.parse(jsonStr);apps/mobile/src/hooks/useExternalPlaylist.ts (1)
9-23: 冗余的空值检查第 12 行的
if (!playlistId) return null检查是多余的,因为第 22 行的enabled: !!playlistId已经确保了当playlistId为空时不会执行queryFn。可以移除这个检查以简化代码。♻️ 建议的修改
return useQuery({ queryKey: ['external-playlist', source, playlistId], queryFn: async () => { - if (!playlistId) return null const result = await externalPlaylistService.fetchExternalPlaylist( playlistId, source, ) if (result.isErr()) { throw result.error } return result.value }, enabled: !!playlistId, })apps/mobile/src/lib/api/kugou/api.ts (1)
142-144: 不必要的类型断言
bestMatch.remoteId as string是多余的,因为根据LyricSearchResult类型定义,当source为'kugou'时,remoteId已经是string类型。♻️ 建议的修改
- return this.getLyrics(bestMatch.remoteId as string).map((content) => + return this.getLyrics(bestMatch.remoteId).map((content) => this.parseLyrics(content), )apps/mobile/src/hooks/queries/lyrics/index.ts (1)
54-54: 移除调试用的 console.log建议移除或替换这些
console.log语句。可以使用项目中已有的log工具进行调试日志记录,或者直接删除这些调试语句。同样适用于第 71 行和第 88 行的
console.log。apps/mobile/src/app/(tabs)/settings/playback.tsx (1)
217-243: LGTM!新增的三个歌词源选项实现与现有选项保持一致,逻辑正确。
如有需要,可以考虑将重复的
Checkbox.Item配置抽取为数组映射,以减少代码重复:♻️ 可选重构:抽取为配置驱动
const lyricSources = [ { key: 'netease', label: '网易云音乐' }, { key: 'qqmusic', label: 'QQ 音乐' }, { key: 'kuwo', label: '酷我音乐' }, { key: 'kugou', label: '酷狗音乐' }, { key: 'baidu', label: '百度音乐' }, ] as const // 在 JSX 中: {lyricSources.map(({ key, label }) => ( <Checkbox.Item key={key} mode='ios' label={label} status={lyricSource === key ? 'checked' : 'unchecked'} onPress={() => { setSettings({ lyricSource: key }) setLyricSourceMenuVisible(false) }} /> ))}packages/external-playlist-sync/tsconfig.json (1)
1-17: LGTM!TypeScript 配置合理,
NodeNext模块解析与代码中使用的.js扩展名导入匹配。如果该包需要被工作区内其他包消费类型,可考虑添加声明文件输出:
💡 可选:启用声明文件生成
{ "compilerOptions": { "target": "ESNext", "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ESNext"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist", "rootDir": "./", - "resolveJsonModule": true + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true }, "include": ["./**/*.ts"], "exclude": ["node_modules", "dist"] }packages/external-playlist-sync/index.ts (1)
14-14:JSON.parse结果直接类型断言存在风险。如果
result不是有效的 JSON 或结构不符合NeteasePlaylistResponse,运行时会出错但 TypeScript 无法捕获。建议添加基本的运行时校验。🛡️ 建议添加基础校验
- const data = JSON.parse(result) as NeteasePlaylistResponse + const data: unknown = JSON.parse(result) + if ( + !data || + typeof data !== 'object' || + !('playlist' in data) || + !data.playlist?.tracks + ) { + throw new Error('Invalid playlist response structure') + } + const playlistData = data as NeteasePlaylistResponse或考虑使用
zod等库进行 schema 验证。packages/external-playlist-sync/src/types/netease.ts (1)
40-45: 建议将tns字段标记为可选。根据网易云音乐 API 实际返回的数据,
tns(翻译名称)字段在大多数情况下可能不存在。建议与NeteaseSong中的tns?: string[]保持一致。♻️ 建议修改
export interface NeteaseArtist { id: number name: string - tns: string[] + tns?: string[] alias: string[] } export interface NeteaseAlbum { id: number name: string picUrl: string - tns: string[] + tns?: string[] }Also applies to: 47-52
apps/mobile/src/components/modals/playlist/InputExternalPlaylistInfo.tsx (1)
55-69:onValueChange缺少类型断言。
SegmentedButtons的onValueChange回调参数类型为string,但setSource期望'netease' | 'qq'。建议添加类型断言以确保类型安全。♻️ 建议修改
<SegmentedButtons value={source} - onValueChange={(value) => setSource(value)} + onValueChange={(value) => setSource(value as 'netease' | 'qq')} buttons={[ { value: 'netease', label: '网易云音乐', }, { value: 'qq', label: 'QQ音乐', }, ]} style={styles.segmentedButtons} />packages/meting/rollup.config.js (1)
28-36: 版本注入可考虑使用更健壮的方案。当前使用简单的
code.replace('__VERSION__', ...)可能会匹配到字符串字面量中的意外内容。可以考虑使用@rollup/plugin-replace或更精确的正则表达式。♻️ 使用 `@rollup/plugin-replace` 的替代方案
import replace from '@rollup/plugin-replace'; // 在 plugins 数组中替换自定义插件 replace({ preventAssignment: true, values: { __VERSION__: JSON.stringify(packageInfo.version) } })apps/mobile/src/lib/services/lyricService.ts (1)
183-184: 考虑修复类型定义而非使用@ts-expect-error。使用
@ts-expect-error来抑制类型错误表明settings.lyricSource的类型定义与getBestMatchedLyrics的source参数类型不匹配。建议更新lyricSource设置的类型定义,使其与函数签名一致,而非抑制类型检查。Also applies to: 197-198
packages/meting/src/providers/netease.js (2)
5-6:EAPI_IV常量未被使用。
EAPI_IV被定义但从未在代码中使用。eapiEncrypt方法使用的是 AES-128-ECB 模式(第 220 行),该模式不需要 IV。如果不需要此常量,建议移除以保持代码整洁。♻️ 建议移除未使用的常量
// eapi 相关常量 const EAPI_KEY = 'e82ckenh8dichen8'; -const EAPI_IV = Buffer.from('0102030405060708');
277-293: 存在未使用的私有方法。以下方法在当前实现中未被调用:
_generateRandomIP()(Lines 282-293)_bchexdec()(Lines 339-341)_str2hex()(Lines 343-345)_powMod()(Lines 350-362)如果这些是为未来功能预留的,建议添加注释说明;否则考虑移除以减少代码维护负担。
Also applies to: 339-362
packages/meting/src/providers/kuwo.js (2)
111-122:br参数未被使用。
url(id, br = 320)方法接收比特率参数但在请求体中未使用该值,与其他 Provider 的实现不一致。如果酷我 API 不支持指定比特率,建议在方法注释中说明。
204-217: 歌词时间戳精度可能存在问题。
msec的计算使用toFixed(0)进行四舍五入,可能导致歌词同步时出现细微偏差。考虑使用Math.floor或Math.round明确取整策略。const msec = Math.floor((time % 1) * 100).toString().padStart(2, '0');apps/mobile/src/app/playlist/external-sync.tsx (1)
51-60: 建议移除注释掉的样式代码。Lines 56-59 包含被注释掉的样式代码,这些应该被移除以保持代码整洁。
♻️ 建议移除注释代码
<View style={[ styles.itemContainer, - { - // backgroundColor: theme.colors.elevation.level1, // Removed card style - // marginBottom: 12, // Removed spacing - // borderRadius: 12, // Removed border radius - }, ]} >packages/meting/src/meting.js (1)
62-62: 未使用的参数headerOnly参数
headerOnly已声明但从未在函数体中使用,应移除或实现相关逻辑。♻️ 建议修复
- async _curl(url, payload = null, headerOnly = false) { + async _curl(url, payload = null) {apps/mobile/src/types/apis/netease.ts (3)
43-48: 建议将tns和alias设为可选属性根据网易云 API 的实际响应,
tns(翻译名)和alias(别名)字段可能不存在或为空。将这些属性设为必填可能导致类型不匹配。♻️ 建议修复
export interface NeteaseArtist { id: number name: string - tns: string[] - alias: string[] + tns?: string[] + alias?: string[] }
50-55: 建议将tns设为可选属性与
NeteaseArtist相同,专辑的翻译名字段可能不存在。♻️ 建议修复
export interface NeteaseAlbum { id: number name: string picUrl: string - tns: string[] + tns?: string[] }
57-67: 建议将tlyric设为可选属性翻译歌词 (
tlyric) 并非所有歌曲都有,API 可能返回空对象或 null。♻️ 建议修复
export interface NeteaseLyricResponse { lrc: { version: number lyric: string } - tlyric: { + tlyric?: { version: number lyric: string } code: number }packages/meting/src/providers/base.js (1)
130-164: 避免直接修改传入的api对象
executeRequest方法直接修改了传入的api参数(第 133、139、140 行),这可能导致调用方复用该对象时出现意外行为。♻️ 建议:使用浅拷贝避免副作用
async executeRequest(api, meting) { + // 避免修改原始对象 + api = { ...api }; + // 如果有编码方法,先进行编码 if (api.encode) { api = await this.handleEncode(api); }
| @@ -0,0 +1 @@ | |||
| declare module '@roitium/meting' | |||
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
ambient 声明过于简单,导致类型丢失。
当前声明未导出任何类型,导致 Meting 类及其方法被推断为 any,这正是 index.ts 中静态分析报错的根源(Unsafe construction of an any typed value 等)。
建议补充最小类型定义:
🔧 建议添加类型定义
-declare module '@roitium/meting'
+declare module '@roitium/meting' {
+ type Source = 'netease' | 'tencent' | 'kugou' | 'kuwo' | 'baidu'
+
+ export default class Meting {
+ constructor(source: Source)
+ playlist(id: string): Promise<string>
+ // 根据实际使用补充其他方法
+ }
+}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| declare module '@roitium/meting' | |
| declare module '@roitium/meting' { | |
| type Source = 'netease' | 'tencent' | 'kugou' | 'kuwo' | 'baidu' | |
| export default class Meting { | |
| constructor(source: Source) | |
| playlist(id: string): Promise<string> | |
| // 根据实际使用补充其他方法 | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@packages/external-playlist-sync/meting.d.ts` at line 1, The ambient module
declaration is too minimal and causes the Meting class and its methods to be
typed as any; update the declaration for module '@roitium/meting' to export a
minimal set of types so static analysis can type-check usage: declare and export
a Meting class (symbol: Meting) with a typed constructor (e.g.,
constructor(options?: Record<string, any>)), and add method signatures used by
the codebase such as init(): Promise<void>, search(query: string, source?:
string): Promise<any>, getSong(id: string): Promise<any>, getPlaylist(id:
string): Promise<any> (and any other methods your index.ts calls), plus export
the class as the module default or named export to match how it is imported;
include simple interfaces/types for option objects if referenced so the compiler
no longer infers any.
| "devDependencies": { | ||
| "@roitium/meting": "workspace:*" | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
rg -n "@roitium/meting" packages/external-playlist-sync -g "*.ts"Repository: bbplayer-app/BBPlayer
Length of output: 223
🏁 Script executed:
head -20 packages/external-playlist-sync/package.json
cat -n packages/external-playlist-sync/index.ts | head -10Repository: bbplayer-app/BBPlayer
Length of output: 720
将 @roitium/meting 从 devDependencies 移到 dependencies。
该包在 index.ts(主入口点)第 1 行进行运行时导入,并在第 8 行实际使用。将其放在 devDependencies 会导致生产环境安装(--production 或 --omit=dev)时模块缺失,引发模块解析失败。
建议的修改
- "devDependencies": {
- "@roitium/meting": "workspace:*"
- }
+ "dependencies": {
+ "@roitium/meting": "workspace:*"
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "devDependencies": { | |
| "@roitium/meting": "workspace:*" | |
| } | |
| "dependencies": { | |
| "@roitium/meting": "workspace:*" | |
| } |
🤖 Prompt for AI Agents
In `@packages/external-playlist-sync/package.json` around lines 10 - 12, The
package `@roitium/meting` is currently listed under devDependencies but is
imported at runtime in index.ts (import of `@roitium/meting` at top and use around
line 8); move "@roitium/meting" from devDependencies into the dependencies
object in package.json so it is installed in production builds (ensure the exact
package key "@roitium/meting" is added under "dependencies" and removed from
"devDependencies").
| // 添加超时控制 | ||
| const controller = new AbortController(); | ||
| const timeoutId = setTimeout(() => controller.abort(), 20000); | ||
| requestOptions.signal = controller.signal; | ||
|
|
||
| let retries = 3; | ||
| const makeRequest = async () => { | ||
| try { | ||
| const response = await fetch(url, requestOptions); | ||
|
|
||
| clearTimeout(timeoutId); | ||
|
|
||
| // 存储响应信息 | ||
| this.info = { | ||
| statusCode: response.status, | ||
| headers: Object.fromEntries(response.headers.entries()) | ||
| }; | ||
|
|
||
| // 获取响应数据 | ||
| const data = await response.text(); | ||
| this.raw = data; | ||
| this.error = null; | ||
| this.status = ''; | ||
|
|
||
| return this; | ||
| } catch (err) { | ||
| clearTimeout(timeoutId); | ||
|
|
||
| // 处理错误 | ||
| if (err.name === 'AbortError') { | ||
| this.error = 'TIMEOUT'; | ||
| this.status = 'Request timeout'; | ||
| } else { | ||
| this.error = err.code || err.name; | ||
| this.status = err.message; | ||
| } | ||
|
|
||
| // 重试机制 | ||
| if (retries > 0) { | ||
| retries--; | ||
| await new Promise(resolve => setTimeout(resolve, 1000)); | ||
| return makeRequest(); | ||
| } else { | ||
| return this; | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| return await makeRequest(); | ||
| } |
There was a problem hiding this comment.
超时控制和重试逻辑存在问题
-
AbortController 无法跨重试复用:
AbortController一旦调用abort()后就无法重置。当前实现在重试时仍使用同一个已中止的 controller,导致后续重试请求会立即失败。 -
超时未覆盖
response.text():clearTimeout在fetch返回后立即调用,但response.text()的读取过程不受超时保护,可能导致长时间挂起。
🐛 建议修复
- // 添加超时控制
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 20000);
- requestOptions.signal = controller.signal;
-
let retries = 3;
const makeRequest = async () => {
+ // 每次重试创建新的 AbortController
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 20000);
+
try {
- const response = await fetch(url, requestOptions);
-
- clearTimeout(timeoutId);
+ const response = await fetch(url, { ...requestOptions, signal: controller.signal });
// 存储响应信息
this.info = {
statusCode: response.status,
headers: Object.fromEntries(response.headers.entries())
};
// 获取响应数据
const data = await response.text();
+ clearTimeout(timeoutId);
this.raw = data;
this.error = null;
this.status = '';
return this;
} catch (err) {
clearTimeout(timeoutId);🤖 Prompt for AI Agents
In `@packages/meting/src/meting.js` around lines 77 - 126, The current
retry/timeout logic reuses a single AbortController and timeoutId across
attempts and clears the timeout before response.text() finishes, so retries
immediately fail and response.text() remains unprotected; fix by moving creation
of AbortController, timeoutId and assignment requestOptions.signal inside each
attempt (inside makeRequest) so each retry gets a fresh controller and timeout,
start the per-attempt setTimeout that aborts that controller, await both fetch
and response.text() before clearing that attempt's timeout (so response.text()
is also covered), and ensure you set requestOptions.signal = controller.signal
for that attempt and clearTimeout(timeoutId) in both the success and error paths
for that attempt to avoid leaks; reference symbols: AbortController, controller,
timeoutId, requestOptions.signal, makeRequest, fetch, response.text(), retries.
| for (let i = 0; i < qualityMap.length; i++) { | ||
| const [sizeKey, br, prefix, ext] = qualityMap[i]; | ||
| if (data.data[0].file[sizeKey] && br <= this.meting.temp.br) { | ||
| if (vkeys[i].vkey) { | ||
| url = { | ||
| url: response.req_0.data.sip[0] + vkeys[i].purl, | ||
| size: data.data[0].file[sizeKey], | ||
| br: br | ||
| }; | ||
| break; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
访问 data.data[0] 前应检查数组是否为空。
若 API 返回空数组,data.data[0] 为 undefined,后续访问 .file 和 .mid 会抛出 TypeError。
🛡️ 建议的修复方案
const response = JSON.parse(await this.meting._exec(api));
const vkeys = response.req_0.data.midurlinfo;
+ if (!data.data || data.data.length === 0) {
+ return JSON.stringify({ url: '', size: 0, br: -1 });
+ }
+
let url;
for (let i = 0; i < qualityMap.length; i++) {🤖 Prompt for AI Agents
In `@packages/meting/src/providers/tencent.js` around lines 254 - 266, Before
accessing data.data[0] inside the qualityMap loop, add a guard to verify the API
response contains an array with at least one element (e.g., check data &&
Array.isArray(data.data) && data.data.length > 0) and handle the empty case
(return null/undefined or skip processing) to avoid TypeError; update the loop
using the existing symbols (qualityMap, vkeys, response.req_0, vkeys[i].purl,
and the accesses to .file and .mid) so you only read data.data[0].file and
data.data[0].mid after the presence check and provide a clear fallback path.
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Fix all issues with AI agents
In `@apps/mobile/src/hooks/queries/lyrics/index.ts`:
- Around line 125-143: The effect currently lists processedProviders in its
dependency array while also mutating it inside (via setProcessedProviders),
which risks unnecessary reruns and closure bugs; change to track processed
providers with a useRef (e.g., processedProvidersRef) instead of state so you
can remove processedProviders from the useEffect deps, keep neteaseData, qqData,
kugouData in the dependency array, and update results via setResults(prev =>
[...prev, ...data]) only when processedProvidersRef.current lacks the provider;
after appending results mark processedProvidersRef.current.add(provider) rather
than calling setProcessedProviders to avoid the circular dependency.
In `@apps/mobile/src/lib/api/kugou/api.ts`:
- Around line 101-111: Before decoding downloadRes.content, validate the
response status and presence of content: inside the
ResultAsync.fromPromise().map callback that receives downloadRes (from the fetch
-> KugouLyricDownloadResponse flow), check downloadRes.status for success (e.g.
200) and ensure downloadRes.content is non-empty; if invalid, throw a
descriptive Error so the ResultAsync rejects rather than calling
CryptoJS.enc.Base64.parse on an empty/invalid value. Update the branch that
currently decodes using CryptoJS.enc.Base64.parse(raw) /
CryptoJS.enc.Utf8.stringify(word) to run only after these checks.
In `@apps/mobile/src/lib/api/qqmusic/api.ts`:
- Around line 173-203: The getPlaylist method is missing the optional
AbortSignal parameter used elsewhere; update the public getPlaylist(id: string)
signature to accept signal?: AbortSignal and pass that signal into the fetch
call options so it behaves like search(), getLyrics(), and
searchBestMatchedLyrics(); keep the same error handling and
ResultAsync.fromPromise wrapper but include the signal in the fetch options to
enable request cancellation.
In `@apps/mobile/src/lib/services/externalPlaylistService.ts`:
- Around line 63-85: The mapping in the qqMusicApi.getPlaylist response (inside
the .map callback that builds playlist and tracks) assumes playlist.songlist,
each track.singer, and track.album exist and will throw if any are undefined;
update the code to defensively access these fields using optional chaining and
defaults: treat playlist.songlist as an empty array if falsy, treat track.singer
as an empty array, use track.album?.name and a safe album object fallback,
coerce duration with a numeric fallback, and only build coverUrl when
track.album?.mid exists (or use a default image); ensure translatedTitle falls
back to an empty string and that tracks mapping always returns a valid object
structure so the function degrades gracefully on missing QQ fields.
- Around line 102-112: 当前在 externalPlaylistService.ts 的返回分支使用 okAsync
返回一个默认“空”结果(函数中返回的 okAsync({...})会掩盖不支持的 source 情况);请将该分支改为返回 errAsync(...)
并传入明确错误信息(例如 "Unsupported source" 或包含 source 值的描述),同时在文件顶部新增 errAsync
的导入以替换/补充现有 okAsync 使用,确保调用方能接收到失败语义而非假成功。
In `@apps/mobile/src/lib/services/lyricService.ts`:
- Around line 206-207: The code currently casts lyricSource with
`@ts-expect-error` to call getBestMatchedLyrics while getBestMatchedLyrics only
accepts 'auto' | 'netease' | 'qqmusic' | 'kugou'; either extend
getBestMatchedLyrics' signature to include 'kuwo' and 'baidu' and implement
their provider search logic (and the underlying API client/provider
implementations) and ensure callers like the site using lyricSource pass the new
union, or remove 'kuwo' and 'baidu' from the settings in appStore.ts so they
never reach getBestMatchedLyrics; also remove the `@ts-expect-error`, update all
type annotations/usages of getBestMatchedLyrics, and guard against
Promise.any([]) by ensuring at least one provider is added or by handling the
empty-array case in getBestMatchedLyrics.
🧹 Nitpick comments (9)
apps/mobile/app.config.ts (1)
136-139: 明文 HTTP 被全量启用,存在传输安全风险将
usesCleartextTraffic直接设为true会让所有域名走明文 HTTP,容易被中间人攻击。建议仅在开发/测试环境启用,或通过显式环境变量开关,并在生产保持false(必要时再用域名白名单配置)。✅ 建议的最小改动(仅开发启用)
- usesCleartextTraffic: true, + usesCleartextTraffic: IS_DEV && process.env.ALLOW_CLEARTEXT === 'true',apps/mobile/src/lib/api/netease/api.ts (1)
128-143: 建议为 getPlaylist 增加可选 AbortSignal,统一取消行为。
同步外部歌单场景可能较长,建议与getLyrics/search保持一致。♻️ 建议修改
getPlaylist( id: string, + signal?: AbortSignal, ): ResultAsync<NeteasePlaylistResponse, NeteaseApiError> { const data = { s: '0', id: id, n: '1000', t: '0', } const requestOptions: RequestOptions = createOption({}, 'eapi') + if (signal) { + requestOptions.signal = signal + } return createRequest<object, NeteasePlaylistResponse>( '/api/v6/playlist/detail', data, requestOptions, ).map((res) => res.body) }apps/mobile/src/lib/api/kugou/api.ts (2)
147-149: 不必要的类型断言
bestMatch.remoteId as string是多余的。根据LyricSearchResult类型定义,当source: 'kugou'时,remoteId已经是string类型。♻️ 建议移除类型断言
-return this.getLyrics(bestMatch.remoteId as string, signal).map( +return this.getLyrics(bestMatch.remoteId, signal).map( (content) => this.parseLyrics(content), )
42-42: 所有 Kugou API 端点都使用 HTTP 而非 HTTPS,存在安全风险三个端点(
mobilecdn.kugou.com、krcs.kugou.com、lyrics.kugou.com)均采用明文 HTTP 协议:
- 请求/响应数据易被中间人攻击窃听或篡改
- 在非信任网络环境下可能被拦截
虽然 Kugou 官方文档明确声明官方 API 均使用 HTTPS,建议确认这些移动端点是否也支持 HTTPS 并相应迁移。
apps/mobile/src/lib/services/lyricService.ts (2)
69-98: 并发取消逻辑存在边界问题
createProviderPromise中的取消逻辑在首个 provider 成功后会中止其他请求,这是合理的。但有两个潜在问题:
- 当
apiCall因被 abort 而抛出AbortError时,这个错误会被传递到Promise.any,可能污染错误聚合- 如果所有 provider 都被 abort(理论上不应发生),
Promise.any会收到全是AbortError的聚合错误当前实现在正常场景下工作正常,但建议在错误消息中过滤掉
AbortError,以提供更清晰的失败信息。
140-155: Promise.any 错误处理基本正确使用
Promise.any配合AggregateError处理是正确的模式。建议增加对aggregateError.errors不存在情况的防御性处理。🛡️ 建议增加防御性检查
return ResultAsync.fromPromise(Promise.any(providers), (e) => { // All failed // e will be an AggregateError if using Promise.any const aggregateError = e as AggregateError - const errors = Array.from(aggregateError.errors || []) + const errors = Array.isArray(aggregateError.errors) + ? aggregateError.errors + : [aggregateError] const errorMessages = errorsapps/mobile/src/hooks/queries/lyrics/index.ts (2)
52-55: 状态更新链可以合并优化静态分析工具提示避免链式状态更新。当
searchQuery变化时,setResults和setProcessedProviders可以合并为单次更新,或使用useReducer来管理相关状态。♻️ 建议使用函数式更新或合并状态
+// 方案1: 合并为单一状态对象 +const [searchState, setSearchState] = useState<{ + results: LyricSearchResult + processedProviders: Set<string> +}>({ results: [], processedProviders: new Set() }) // Effect to reset results when query changes useEffect(() => { - setResults([]) - setProcessedProviders(new Set()) + setSearchState({ results: [], processedProviders: new Set() }) }, [searchQuery])
66-66: 调试用的console.log应移除或替换为 logger生产代码中不应保留
console.log调试语句。建议移除或使用项目中统一的log工具(如@/utils/log)。♻️ 建议移除或替换
-console.log('Searching Netease:', searchQuery)-console.log('Searching QQ:', searchQuery)-console.log('Searching Kugou:', searchQuery)Also applies to: 86-86, 103-103
apps/mobile/src/lib/services/externalPlaylistService.ts (1)
126-137: 取消信号的响应可以更及时。
当前会至少等待MIN_DELAY才响应 abort,用户取消后仍会卡住一段时间。建议改为可中断的 wait。♻️ 建议改为可中断等待
- await wait(MIN_DELAY) + await waitWithAbort(MIN_DELAY, options?.signal)const waitWithAbort = (ms: number, signal?: AbortSignal) => new Promise<void>((resolve, reject) => { const t = setTimeout(resolve, ms) if (!signal) return if (signal.aborted) { clearTimeout(t) return reject(new Error('Aborted')) } const onAbort = () => { clearTimeout(t) signal.removeEventListener('abort', onAbort) reject(new Error('Aborted')) } signal.addEventListener('abort', onAbort, { once: true }) })
| return ResultAsync.fromPromise( | ||
| fetch(downloadUrl, { signal }).then( | ||
| (res) => res.json() as Promise<KugouLyricDownloadResponse>, | ||
| ), | ||
| (e) => new Error('Failed to download lyric from Kugou', { cause: e }), | ||
| ).map((downloadRes) => { | ||
| // Decode Base64 content | ||
| const raw = downloadRes.content | ||
| const word = CryptoJS.enc.Base64.parse(raw) | ||
| return CryptoJS.enc.Utf8.stringify(word) | ||
| }) |
There was a problem hiding this comment.
下载歌词时缺少状态码校验
在解码 downloadRes.content 之前,未检查 downloadRes.status。若服务端返回错误状态或 content 为空,CryptoJS.enc.Base64.parse(raw) 可能产生意外结果。
🛡️ 建议添加状态校验
return ResultAsync.fromPromise(
fetch(downloadUrl, { signal }).then(
(res) => res.json() as Promise<KugouLyricDownloadResponse>,
),
(e) => new Error('Failed to download lyric from Kugou', { cause: e }),
).andThen((downloadRes) => {
+ if (downloadRes.status !== 200 || !downloadRes.content) {
+ return errAsync(new Error('Invalid lyric download response from Kugou'))
+ }
// Decode Base64 content
const raw = downloadRes.content
const word = CryptoJS.enc.Base64.parse(raw)
- return CryptoJS.enc.Utf8.stringify(word)
+ return okAsync(CryptoJS.enc.Utf8.stringify(word))
})🤖 Prompt for AI Agents
In `@apps/mobile/src/lib/api/kugou/api.ts` around lines 101 - 111, Before decoding
downloadRes.content, validate the response status and presence of content:
inside the ResultAsync.fromPromise().map callback that receives downloadRes
(from the fetch -> KugouLyricDownloadResponse flow), check downloadRes.status
for success (e.g. 200) and ensure downloadRes.content is non-empty; if invalid,
throw a descriptive Error so the ResultAsync rejects rather than calling
CryptoJS.enc.Base64.parse on an empty/invalid value. Update the branch that
currently decodes using CryptoJS.enc.Base64.parse(raw) /
CryptoJS.enc.Utf8.stringify(word) to run only after these checks.
| /** | ||
| * Get playlist by id | ||
| * @param id | ||
| * @returns | ||
| */ | ||
| public getPlaylist(id: string): ResultAsync<QQMusicPlaylistResponse, Error> { | ||
| const params = new URLSearchParams({ | ||
| id, | ||
| format: 'json', | ||
| newsong: '1', | ||
| platform: 'jqspaframe.json', | ||
| }) | ||
|
|
||
| const url = `https://c.y.qq.com/v8/fcg-bin/fcg_v8_playlist_cp.fcg?${params.toString()}` | ||
|
|
||
| return ResultAsync.fromPromise( | ||
| fetch(url, { | ||
| headers: { | ||
| Referer: 'http://y.qq.com', | ||
| 'User-Agent': | ||
| 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0', | ||
| }, | ||
| }).then((res) => { | ||
| if (!res.ok) { | ||
| throw new Error(`QQ Music API error: ${res.statusText}`) | ||
| } | ||
| return res.json() as Promise<QQMusicPlaylistResponse> | ||
| }), | ||
| (e) => new Error('Failed to fetch playlist from QQ Music', { cause: e }), | ||
| ) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's find the file and examine its structure
fd api.ts apps/mobile/src/lib/api/qqmusic/Repository: bbplayer-app/BBPlayer
Length of output: 103
🏁 Script executed:
# Let's examine the API class to see all methods and their signatures
cat -n apps/mobile/src/lib/api/qqmusic/api.ts | head -250Repository: bbplayer-app/BBPlayer
Length of output: 6801
🏁 Script executed:
# Check if there's a type definition file for QQMusicPlaylistResponse
fd -i "qqmusic\|types\|interface" apps/mobile/src/lib/api/Repository: bbplayer-app/BBPlayer
Length of output: 47
🏁 Script executed:
# Search for AbortSignal usage in the codebase to understand the pattern
rg "AbortSignal" apps/mobile/src/lib/api/Repository: bbplayer-app/BBPlayer
Length of output: 870
🏁 Script executed:
# Find the types file for QQMusic APIs
fd qqmusic types/Repository: bbplayer-app/BBPlayer
Length of output: 158
🏁 Script executed:
# Look at the type definitions
cat -n apps/mobile/src/types/apis/qqmusic.ts 2>/dev/null || cat -n apps/mobile/src/types/apis/qqmusic/index.ts 2>/dev/nullRepository: bbplayer-app/BBPlayer
Length of output: 2037
🏁 Script executed:
# Search for QQMusicPlaylistResponse definition
rg "QQMusicPlaylistResponse" apps/mobile/src/Repository: bbplayer-app/BBPlayer
Length of output: 425
在 getPlaylist() 中补齐 AbortSignal 参数以保持一致性。
search()、getLyrics() 和 searchBestMatchedLyrics() 都有 signal?: AbortSignal 参数,但 getPlaylist() 缺失。建议添加该参数并传递给 fetch 以与其他方法保持一致。
🔧 建议改动(补齐 signal 参数)
- public getPlaylist(id: string): ResultAsync<QQMusicPlaylistResponse, Error> {
+ public getPlaylist(
+ id: string,
+ signal?: AbortSignal,
+ ): ResultAsync<QQMusicPlaylistResponse, Error> {
const params = new URLSearchParams({
id,
format: 'json',
newsong: '1',
platform: 'jqspaframe.json',
})
const url = `https://c.y.qq.com/v8/fcg-bin/fcg_v8_playlist_cp.fcg?${params.toString()}`
return ResultAsync.fromPromise(
fetch(url, {
headers: {
Referer: 'http://y.qq.com',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
},
+ signal,
}).then((res) => {
if (!res.ok) {
throw new Error(`QQ Music API error: ${res.statusText}`)
}
return res.json() as Promise<QQMusicPlaylistResponse>
}),
(e) => new Error('Failed to fetch playlist from QQ Music', { cause: e }),
)
}🤖 Prompt for AI Agents
In `@apps/mobile/src/lib/api/qqmusic/api.ts` around lines 173 - 203, The
getPlaylist method is missing the optional AbortSignal parameter used elsewhere;
update the public getPlaylist(id: string) signature to accept signal?:
AbortSignal and pass that signal into the fetch call options so it behaves like
search(), getLyrics(), and searchBestMatchedLyrics(); keep the same error
handling and ResultAsync.fromPromise wrapper but include the signal in the fetch
options to enable request cancellation.
| return qqMusicApi.getPlaylist(playlistId).map((response) => { | ||
| const playlist = response.data.cdlist[0] | ||
| if (!playlist) | ||
| return { | ||
| playlist: { | ||
| id: playlistId, | ||
| title: 'Unknown', | ||
| coverUrl: '', | ||
| description: '', | ||
| trackCount: 0, | ||
| author: { name: 'Unknown' }, | ||
| }, | ||
| tracks: [], | ||
| } | ||
|
|
||
| const tracks = playlist.songlist.map((track) => ({ | ||
| title: track.name, | ||
| artists: track.singer.map((s) => s.name), | ||
| album: track.album.name, | ||
| duration: track.interval * 1000, | ||
| coverUrl: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${track.album.mid}.jpg`, | ||
| translatedTitle: track.subtitle, | ||
| })) |
There was a problem hiding this comment.
QQ 返回字段缺失时可能直接抛异常。
songlist / singer / album 任一为 undefined 就会在 map 或字段访问时崩溃。建议加默认值与可选链,保证外部 API 异常数据下也能降级运行。
🔧 建议修复(增加防御性字段处理)
- const tracks = playlist.songlist.map((track) => ({
- title: track.name,
- artists: track.singer.map((s) => s.name),
- album: track.album.name,
- duration: track.interval * 1000,
- coverUrl: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${track.album.mid}.jpg`,
- translatedTitle: track.subtitle,
- }))
+ const tracks = (playlist.songlist ?? []).map((track) => ({
+ title: track.name ?? '',
+ artists: (track.singer ?? []).map((s) => s.name).filter(Boolean),
+ album: track.album?.name ?? '',
+ duration: (track.interval ?? 0) * 1000,
+ coverUrl: track.album?.mid
+ ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${track.album.mid}.jpg`
+ : '',
+ translatedTitle: track.subtitle ?? '',
+ }))🤖 Prompt for AI Agents
In `@apps/mobile/src/lib/services/externalPlaylistService.ts` around lines 63 -
85, The mapping in the qqMusicApi.getPlaylist response (inside the .map callback
that builds playlist and tracks) assumes playlist.songlist, each track.singer,
and track.album exist and will throw if any are undefined; update the code to
defensively access these fields using optional chaining and defaults: treat
playlist.songlist as an empty array if falsy, treat track.singer as an empty
array, use track.album?.name and a safe album object fallback, coerce duration
with a numeric fallback, and only build coverUrl when track.album?.mid exists
(or use a default image); ensure translatedTitle falls back to an empty string
and that tracks mapping always returns a valid object structure so the function
degrades gracefully on missing QQ fields.
* fix(mobile): fix version code getter logic * Merge pull request #193 from bbplayer-app/feat/nitro-fetch * fix(orpheus, mobile): disable usesCleartextTraffic, only allow hdslb.com use http * fix(mobile): disable r8 * fix(mobile): update proguard rules and enable R8 * chore(mobile): disable r8 * fix(mobile): r8 made @nandrorjo/galeria raise error * feat: sync external playlist (#194) * feat(mobile): e2e (#195) * feat(mobile): 1 (#196) * fix(mobile): add more current state check I don't know why, but I hope it will work. * feat(mobile): some improvements (#198) * feat(orpheus): enhance backend retention capabilities * fix(mobile): player page setpage * fix(orpheus): allow androidx.media3.player wrapping in REPEAT_MODE_ONE (#199) * fix(orpheus): allow androidx.media3.player wrapping in REPEAT_MODE_ONE * chore(mobile): update changelog --------- Co-authored-by: roitium <65794453+roitium@users.noreply.github.com> * feat(mobile): improve playlist UI (#200) * feat(mobile): 1 * feat(mobile): 1 * feat(mobile): 1 * feat(mobile): 1 * feat(mobile): 1 * feat(mobile): 1 * feat(mobile): verbatim lyrics (#201) * feat(mobile, splash): improve verbatim lyrics * perf(mobile): improve verbatim lyrics perf * fix(mobile): player slider cannot slide * fix(mobile): react compiler in PlayerLyrics * chore(mobile): remove reanimated logger config * chore(mobile): edit changelog * fix(mobile): desktop lyrics not work * feat(mobile): add firebase analytics integration (#203) * fix(mobile): improve UI on small screen devices * perf(mobile): some perf improvements * feat(mobile): firebase * feat(mobile): add firebase analytics track * chore(mobile): update privacy * chore(mobile): use firebase analytics new api * chore(root): move some libraries * fix(mobile): type * chore(root, mobile): update package.json * chore(docs): update docs * chore(docs): update docs * chore(docs): update docs * chore(root): wiki * chore(root): wiki * chore(orpheus): wiki * feat(mobile): danmaku (#205) * feat(mobile): implement bilibili danmaku api * fix(mobile): use `useWindowDimensions` instead of `Dimensions.get` * feat(mobile): implement * fix(mobile): 1 * chore(mobile): bump version * chore(mobile): update changelog * ci(mobile): wrong secret key * fix(root): ci not work * chore(root): just make sure * chore(root): remove unnecessary easignore * chore(root): remove unnecessary easignore * chore(root): update update json --------- Co-authored-by: Deyu Wang <kvtoDev@outlook.com>
* fix(mobile): fix version code getter logic * Merge pull request #193 from bbplayer-app/feat/nitro-fetch * fix(orpheus, mobile): disable usesCleartextTraffic, only allow hdslb.com use http * fix(mobile): disable r8 * fix(mobile): update proguard rules and enable R8 * chore(mobile): disable r8 * fix(mobile): r8 made @nandrorjo/galeria raise error * feat: sync external playlist (#194) * feat(mobile): e2e (#195) * feat(mobile): 1 (#196) * fix(mobile): add more current state check I don't know why, but I hope it will work. * feat(mobile): some improvements (#198) * feat(orpheus): enhance backend retention capabilities * fix(mobile): player page setpage * fix(orpheus): allow androidx.media3.player wrapping in REPEAT_MODE_ONE (#199) * fix(orpheus): allow androidx.media3.player wrapping in REPEAT_MODE_ONE * chore(mobile): update changelog --------- Co-authored-by: roitium <65794453+roitium@users.noreply.github.com> * feat(mobile): improve playlist UI (#200) * feat(mobile): 1 * feat(mobile): 1 * feat(mobile): 1 * feat(mobile): 1 * feat(mobile): 1 * feat(mobile): 1 * feat(mobile): verbatim lyrics (#201) * feat(mobile, splash): improve verbatim lyrics * perf(mobile): improve verbatim lyrics perf * fix(mobile): player slider cannot slide * fix(mobile): react compiler in PlayerLyrics * chore(mobile): remove reanimated logger config * chore(mobile): edit changelog * fix(mobile): desktop lyrics not work * feat(mobile): add firebase analytics integration (#203) * fix(mobile): improve UI on small screen devices * perf(mobile): some perf improvements * feat(mobile): firebase * feat(mobile): add firebase analytics track * chore(mobile): update privacy * chore(mobile): use firebase analytics new api * chore(root): move some libraries * fix(mobile): type * chore(root, mobile): update package.json * chore(docs): update docs * chore(docs): update docs * chore(docs): update docs * chore(root): wiki * chore(root): wiki * chore(orpheus): wiki * feat(mobile): danmaku (#205) * feat(mobile): implement bilibili danmaku api * fix(mobile): use `useWindowDimensions` instead of `Dimensions.get` * feat(mobile): implement * fix(mobile): 1 * chore(mobile): bump version * chore(mobile): update changelog * ci(mobile): wrong secret key * fix(root): ci not work * chore(root): just make sure * chore(root): remove unnecessary easignore * chore(root): remove unnecessary easignore * chore(root): update update json * fix(mobile): auto compile protobuf when prepare * chore(root): update app screenshots * fix(mobile): player controls button not work (#208) * fix(mobile): remove player page animation (#212) * fix(mobile): remove player page animation * chore(mobile): bump version * fix(mobile): cover * chore(mobile): Bump version to 2.3.1 and update release URL Updated version and URL for the release notes. --------- Co-authored-by: Deyu Wang <kvtoDev@outlook.com>
Closes #128
Summary by CodeRabbit
发布说明
新功能
问题修复
文档
✏️ Tip: You can customize this high-level summary in your review settings.