diff --git a/components.d.ts b/components.d.ts index 923437b02..63e62a357 100644 --- a/components.d.ts +++ b/components.d.ts @@ -34,6 +34,8 @@ declare module 'vue' { ComboboxTrigger: typeof import('reka-ui')['ComboboxTrigger'] ComboboxViewport: typeof import('reka-ui')['ComboboxViewport'] ComboboxVirtualizer: typeof import('reka-ui')['ComboboxVirtualizer'] + CommentCard: typeof import('./src/components/comments/CommentCard.vue')['default'] + CommentList: typeof import('./src/components/comments/CommentList.vue')['default'] ContextMenuContent: typeof import('reka-ui')['ContextMenuContent'] ContextMenuItem: typeof import('reka-ui')['ContextMenuItem'] ContextMenuPortal: typeof import('reka-ui')['ContextMenuPortal'] @@ -75,6 +77,7 @@ declare module 'vue' { FolderManager: typeof import('./src/components/library/FolderManager.vue')['default'] FontConfig: typeof import('./src/components/settings/custom/FontConfig.vue')['default'] FullPlayer: typeof import('./src/components/player/FullPlayer/index.vue')['default'] + FullPlayerComments: typeof import('./src/components/player/FullPlayer/FullPlayerComments.vue')['default'] HotkeyConfig: typeof import('./src/components/settings/custom/HotkeyConfig.vue')['default'] IconLucideArrowLeftRight: typeof import('~icons/lucide/arrow-left-right')['default'] IconLucideArrowRight: typeof import('~icons/lucide/arrow-right')['default'] @@ -164,6 +167,7 @@ declare module 'vue' { IconLucideSkipBack: typeof import('~icons/lucide/skip-back')['default'] IconLucideSkipForward: typeof import('~icons/lucide/skip-forward')['default'] IconLucideTextQuote: typeof import('~icons/lucide/text-quote')['default'] + IconLucideThumbsUp: typeof import('~icons/lucide/thumbs-up')['default'] IconLucideTrash: typeof import('~icons/lucide/trash')['default'] IconLucideTrash2: typeof import('~icons/lucide/trash2')['default'] IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default'] diff --git a/electron/main/ipc/comments.ts b/electron/main/ipc/comments.ts index e4dbdbf0c..f548f8d10 100644 --- a/electron/main/ipc/comments.ts +++ b/electron/main/ipc/comments.ts @@ -1,7 +1,14 @@ import { ipcMain } from "electron"; import { getCommentSources, getMusicComments } from "@main/services/comments"; +import { getCreatorComments } from "@main/services/comments/creator"; +import { store } from "@main/store"; import { coreLog } from "@main/utils/logger"; -import type { MusicCommentQuery, MusicCommentResponse } from "@shared/types/comment"; +import type { + MusicCommentCreatorQuery, + MusicCommentCreatorResponse, + MusicCommentQuery, + MusicCommentResponse, +} from "@shared/types/comment"; /** 注册评论 IPC */ export const registerCommentsIpc = (): void => { @@ -18,4 +25,17 @@ export const registerCommentsIpc = (): void => { } }, ); + + ipcMain.handle( + "comments:creator", + async (_evt, args: MusicCommentCreatorQuery): Promise => { + if (!store.get("comments.creatorEnabled")) return { ok: true, data: [] }; + try { + return { ok: true, data: await getCreatorComments(args) }; + } catch (err) { + coreLog.warn("[comments] creator failed:", err); + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + ); }; diff --git a/electron/main/services/comments/cache.spec.ts b/electron/main/services/comments/cache.spec.ts new file mode 100644 index 000000000..86527fbff --- /dev/null +++ b/electron/main/services/comments/cache.spec.ts @@ -0,0 +1,83 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BoundedMap, coalesce } from "./cache"; + +describe("评论缓存 BoundedMap", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("负缓存条目同样参与访问提升", () => { + const cache = new BoundedMap(2); + cache.set("a", null); + cache.set("b", "b"); + expect(cache.get("a")).toBeNull(); + cache.set("c", "c"); + expect(cache.get("a")).toBeNull(); + expect(cache.get("b")).toBeUndefined(); + }); + + it("过期条目读取时返回 undefined 并被删除", () => { + const cache = new BoundedMap(2, 1000); + cache.set("a", "a"); + vi.advanceTimersByTime(1001); + expect(cache.get("a")).toBeUndefined(); + }); + + it("包装后能区分缺失与存了 null", () => { + const cache = new BoundedMap(2); + cache.set("a", null); + expect(cache.get("a")).toBeNull(); + expect(cache.get("b")).toBeUndefined(); + }); + + it("超过上限时淘汰最久未使用的条目", () => { + const cache = new BoundedMap(2); + cache.set("a", 1); + cache.set("b", 2); + cache.get("a"); + cache.set("c", 3); + expect(cache.get("b")).toBeUndefined(); + expect(cache.get("a")).toBe(1); + expect(cache.get("c")).toBe(3); + }); +}); + +describe("在途请求合并 coalesce", () => { + it("同 key 并发只调用一次工厂", async () => { + const inflight = new Map>(); + const factory = vi.fn(async () => 1); + const [first, second] = await Promise.all([ + coalesce(inflight, "k", factory), + coalesce(inflight, "k", factory), + ]); + expect(first).toBe(1); + expect(second).toBe(1); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("成功与失败后都移除在途项", async () => { + const inflight = new Map>(); + await coalesce(inflight, "ok", async () => 1); + expect(inflight.size).toBe(0); + await expect( + coalesce(inflight, "fail", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(inflight.size).toBe(0); + }); + + it("不同 key 不共享在途请求", async () => { + const inflight = new Map>(); + const factory = vi.fn(async (value: string) => value); + await Promise.all([ + coalesce(inflight, "a", () => factory("a")), + coalesce(inflight, "b", () => factory("b")), + ]); + expect(factory).toHaveBeenCalledTimes(2); + }); +}); diff --git a/electron/main/services/comments/cache.ts b/electron/main/services/comments/cache.ts new file mode 100644 index 000000000..4936cd980 --- /dev/null +++ b/electron/main/services/comments/cache.ts @@ -0,0 +1,81 @@ +/** 单条缓存记录,包装后 get 的 undefined 才能唯一表示「不存在」 */ +interface CacheEntry { + value: V; + /** 过期时间戳(毫秒),0 表示永不过期 */ + expireAt: number; +} + +/** + * 有界 LRU 缓存,可选用 TTL + * 以条目包装存储,负缓存(null / 空数组)同样参与访问提升与淘汰 + */ +export class BoundedMap { + private readonly map = new Map>(); + + /** + * @param limit - 最大条目数,超出时淘汰最久未使用的条目 + * @param ttlMs - 条目存活时长(毫秒),省略表示永不过期 + */ + constructor( + private readonly limit: number, + private readonly ttlMs?: number, + ) {} + + /** + * 读取缓存值,命中时提升为最近使用,过期条目读取时清除 + * @param key - 缓存键 + * @returns 缓存值,不存在或已过期时返回 undefined + */ + get(key: K): V | undefined { + const entry = this.map.get(key); + if (!entry) return undefined; + if (entry.expireAt && entry.expireAt <= Date.now()) { + this.map.delete(key); + return undefined; + } + if (this.map.size > 1) { + this.map.delete(key); + this.map.set(key, entry); + } + return entry.value; + } + + /** + * 写入缓存值并淘汰超限的最旧条目 + * @param key - 缓存键 + * @param value - 缓存值 + */ + set(key: K, value: V): void { + this.map.delete(key); + this.map.set(key, { + value, + expireAt: this.ttlMs ? Date.now() + this.ttlMs : 0, + }); + while (this.map.size > this.limit) { + const oldest = this.map.keys().next().value; + if (oldest === undefined) break; + this.map.delete(oldest); + } + } +} + +/** + * 合并同 key 的并发请求,Promise 落定后自动移除在途项 + * @param inflight - 在途请求表 + * @param key - 去重键 + * @param factory - 实际请求工厂 + * @returns 共享的请求 Promise + */ +export const coalesce = ( + inflight: Map>, + key: K, + factory: () => Promise, +): Promise => { + const pending = inflight.get(key); + if (pending) return pending; + const promise = factory().finally(() => { + inflight.delete(key); + }); + inflight.set(key, promise); + return promise; +}; diff --git a/electron/main/services/comments/creator.spec.ts b/electron/main/services/comments/creator.spec.ts new file mode 100644 index 000000000..f2f764a6d --- /dev/null +++ b/electron/main/services/comments/creator.spec.ts @@ -0,0 +1,190 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MusicCommentItem } from "@shared/types/comment"; +import type { Track } from "@shared/types/player"; + +const state = vi.hoisted(() => ({ + hot: new Map(), + fresh: new Map(), + artists: new Map(), + calls: [] as string[], + meta: { songId: "100", artistIds: ["500"] } as { songId: string; artistIds: string[] } | null, + metaCalls: 0, + creator: null as unknown as (args: { + sourceId: string; + track: Track; + }) => Promise, +})); + +vi.mock("@main/utils/logger", () => ({ coreLog: { warn: vi.fn(), info: vi.fn() } })); +vi.mock("./index", () => ({ + findNeteaseSongMeta: async () => { + state.metaCalls += 1; + return state.meta; + }, +})); +vi.mock("@main/apis/netease", () => ({ + callNetease: async (name: string, params: { id?: string; offset?: number; limit?: number }) => { + const id = String(params.id ?? ""); + state.calls.push(`${name}:${id}`); + if (name === "artists") return state.artists.get(id) ?? { status: 500, body: {} }; + const page = Math.floor((params.offset ?? 0) / (params.limit ?? 20)) + 1; + const table = name === "comment_hot" ? state.hot : state.fresh; + const registered = table.get(page); + if (registered) return registered; + // 未注册的页视为最后一页 + return { + status: 200, + body: + name === "comment_hot" ? { hotComments: [], more: false } : { comments: [], more: false }, + }; + }, +})); + +const track: Track = { + id: "100", + source: "netease", + title: "歌名", + artists: [{ id: "500", name: "歌手" }], + duration: 180000, +}; + +const creatorComment = (id: string, likedCount: number) => ({ + commentId: id, + content: `内容${id}`, + likedCount, + user: { userId: 200, nickname: "主创" }, +}); + +const listenerComment = (id: string) => ({ + commentId: id, + content: `内容${id}`, + user: { userId: 300, nickname: "听众" }, +}); + +/** + * 注册一页评论响应 + * @param type - hot 热评 / new 普通评论 + * @param page - 页码 + * @param comments - 该页原始评论 + * @param more - 该页是否还有下一页 + */ +const setPage = (type: "hot" | "new", page: number, comments: unknown[], more: boolean): void => { + const body = type === "hot" ? { hotComments: comments, more } : { comments, more }; + (type === "hot" ? state.hot : state.fresh).set(page, { status: 200, body }); +}; + +/** + * 注册歌手账号解析响应 + * @param accountId - 网易云账号 id,0 表示未绑定 + */ +const setArtist = (accountId: number): void => { + state.artists.set( + "500", + accountId > 0 + ? { status: 200, body: { artist: { accountId, name: "歌手" } } } + : { status: 200, body: { artist: { accountId } } }, + ); +}; + +const loadCreator = async (): Promise => { + // 每次重新加载模块,避免进程级缓存跨用例串味 + vi.resetModules(); + state.creator = (await import("./creator")).getCreatorComments; +}; + +describe("主创说抓取", () => { + beforeEach(() => { + state.hot.clear(); + state.fresh.clear(); + state.artists.clear(); + state.calls.length = 0; + state.metaCalls = 0; + state.meta = { songId: "100", artistIds: ["500"] }; + setArtist(200); + }); + + it("热评首页命中时只发一次热评请求", async () => { + setPage("hot", 1, [creatorComment("1", 5), listenerComment("2")], true); + await loadCreator(); + const result = await state.creator({ sourceId: "builtin:netease", track }); + expect(result).toHaveLength(1); + expect(state.calls.filter((call) => call.startsWith("comment_hot"))).toHaveLength(1); + expect(state.calls.filter((call) => call.startsWith("comment_music"))).toHaveLength(0); + }); + + it("热评翻页后无命中转普通评论,首个命中页即停", async () => { + setPage("hot", 1, [listenerComment("1")], true); + setPage("hot", 2, [listenerComment("2")], true); + setPage("hot", 3, [listenerComment("3")], false); + setPage("new", 1, [creatorComment("9", 3)], true); + await loadCreator(); + const result = await state.creator({ sourceId: "builtin:netease", track }); + expect(result).toHaveLength(1); + expect(state.calls.filter((call) => call.startsWith("comment_hot"))).toHaveLength(3); + expect(state.calls.filter((call) => call.startsWith("comment_music"))).toHaveLength(1); + }); + + it("more 为假时两个阶段都立即截断", async () => { + setPage("hot", 1, [listenerComment("1")], false); + setPage("new", 1, [listenerComment("2")], false); + await loadCreator(); + const result = await state.creator({ sourceId: "builtin:netease", track }); + expect(result).toEqual([]); + expect(state.calls.filter((call) => call.startsWith("comment_hot"))).toHaveLength(1); + expect(state.calls.filter((call) => call.startsWith("comment_music"))).toHaveLength(1); + }); + + it("非 200 状态直接失败", async () => { + state.hot.set(1, { status: 500, body: {} }); + await loadCreator(); + await expect(state.creator({ sourceId: "builtin:netease", track })).rejects.toThrow( + "netease comment request failed: 500", + ); + }); + + it("歌手解析失败不写负缓存,后续请求仍会重试", async () => { + state.artists.set("500", { status: 500, body: {} }); + await loadCreator(); + expect(await state.creator({ sourceId: "builtin:netease", track })).toEqual([]); + expect(state.calls.filter((call) => call.startsWith("artists"))).toHaveLength(1); + setArtist(200); + setPage("hot", 1, [creatorComment("1", 5)], false); + expect(await state.creator({ sourceId: "builtin:netease", track })).toHaveLength(1); + expect(state.calls.filter((call) => call.startsWith("artists"))).toHaveLength(2); + }); + + it("歌手未绑定账号时直接返回空,不请求评论", async () => { + setArtist(0); + await loadCreator(); + expect(await state.creator({ sourceId: "builtin:netease", track })).toEqual([]); + expect(state.calls.filter((call) => call.startsWith("comment_hot"))).toHaveLength(0); + }); + + it("同曲第二次调用命中缓存,不再请求元信息与评论", async () => { + setPage("hot", 1, [creatorComment("1", 5)], true); + await loadCreator(); + await state.creator({ sourceId: "builtin:netease", track }); + const calls = state.calls.length; + await state.creator({ sourceId: "builtin:netease", track }); + expect(state.calls.length).toBe(calls); + }); + + it("结果按点赞数降序", async () => { + setPage( + "hot", + 1, + [creatorComment("1", 1), creatorComment("2", 30), creatorComment("3", 10)], + false, + ); + await loadCreator(); + const result = await state.creator({ sourceId: "builtin:netease", track }); + expect(result.map((item) => item.id)).toEqual(["2", "3", "1"]); + }); + + it("非网易云源直接返回空且不发请求", async () => { + await loadCreator(); + expect(await state.creator({ sourceId: "builtin:qqmusic", track })).toEqual([]); + expect(state.calls).toHaveLength(0); + }); +}); diff --git a/electron/main/services/comments/creator.ts b/electron/main/services/comments/creator.ts new file mode 100644 index 000000000..9e108aa59 --- /dev/null +++ b/electron/main/services/comments/creator.ts @@ -0,0 +1,167 @@ +import { callNetease } from "@main/apis/netease"; +import { coreLog } from "@main/utils/logger"; +import type { MusicCommentCreatorQuery, MusicCommentItem } from "@shared/types/comment"; +import { BoundedMap, coalesce } from "./cache"; +import { + normalizeNeteaseCommentPage, + optionalString, + scanCreatorComments, + type NeteaseCommentBody, +} from "./data"; +import { findNeteaseSongMeta } from "./index"; + +const NETEASE_SOURCE_ID = "builtin:netease"; +const NETEASE_RESOURCE_TYPE = "R_SO_4_"; +const PAGE_LIMIT = 20; +const MAX_HOT_PAGES = 3; +const MAX_NEW_PAGES = 5; +const ARTIST_ACCOUNT_CACHE_LIMIT = 200; +const CREATOR_COMMENTS_CACHE_LIMIT = 100; +const CREATOR_COMMENTS_TTL = 10 * 60 * 1000; + +/** 歌手账号解析结果,failed 表示请求异常,不能当作未绑定 */ +type ArtistAccountResult = + | { state: "bound"; accountId: string; artistName: string } + | { state: "unbound" } + | { state: "failed" }; + +/** 歌手 id → 账号解析结果,只缓存确定结论 */ +const artistAccountCache = new BoundedMap(ARTIST_ACCOUNT_CACHE_LIMIT); +/** 网易云歌曲 id → 主创评论列表,空数组为负缓存 */ +const creatorCommentsCache = new BoundedMap( + CREATOR_COMMENTS_CACHE_LIMIT, + CREATOR_COMMENTS_TTL, +); +/** 歌手账号解析在途请求 */ +const artistAccountInflight = new Map>(); +/** 主创评论扫描在途请求 */ +const creatorInflight = new Map>(); + +/** + * 拉取一页网易云评论,非 200 状态抛错 + * @param songId - 网易云歌曲 id + * @param type - 评论分类 + * @param page - 页码 + * @returns 归一化分页,含原生的 hasMore 信号 + */ +const fetchPage = async (songId: string, type: "hot" | "new", page: number) => { + const apiName = type === "hot" ? "comment_hot" : "comment_music"; + const { status, body } = await callNetease(apiName, { + id: songId, + type: NETEASE_RESOURCE_TYPE, + limit: PAGE_LIMIT, + offset: (page - 1) * PAGE_LIMIT, + }); + if (status !== 200) throw new Error(`netease comment request failed: ${status}`); + return normalizeNeteaseCommentPage(body as NeteaseCommentBody, type, page, PAGE_LIMIT); +}; + +/** + * 解析单个歌手绑定的网易云账号 + * 请求异常时不下结论,避免一次超时被当成永久未绑定 + * @param artistId - 歌手 id + * @returns 账号解析结果 + */ +const resolveArtistAccount = async (artistId: string): Promise => { + const cached = artistAccountCache.get(artistId); + if (cached !== undefined) return cached; + return coalesce(artistAccountInflight, artistId, async () => { + try { + const { status, body } = await callNetease("artists", { id: artistId }); + if (status !== 200) throw new Error(`netease artist request failed: ${status}`); + const rawAccountId = body?.artist?.accountId; + const accountId = + typeof rawAccountId === "number" && rawAccountId > 0 + ? String(rawAccountId) + : typeof rawAccountId === "string" && rawAccountId + ? rawAccountId + : ""; + const result: ArtistAccountResult = accountId + ? { state: "bound", accountId, artistName: optionalString(body?.artist?.name) ?? "" } + : { state: "unbound" }; + artistAccountCache.set(artistId, result); + return result; + } catch (err) { + coreLog.warn(`[comments] resolve artist ${artistId} accountId failed:`, err); + return { state: "failed" }; + } + }); +}; + +/** + * 解析歌手列表对应的网易云账号 + * @param artistIds - 歌手 id 列表 + * @returns 账号 id → 歌手名,以及是否存在解析失败的歌手 + */ +const resolveArtistAccountIds = async ( + artistIds: string[], +): Promise<{ accountMap: Map; failed: boolean }> => { + const accountMap = new Map(); + const uniqueIds = [...new Set(artistIds.filter(Boolean))]; + const results = await Promise.all(uniqueIds.map((artistId) => resolveArtistAccount(artistId))); + let failed = false; + for (const result of results) { + if (result.state === "bound") accountMap.set(result.accountId, result.artistName); + else if (result.state === "failed") failed = true; + } + return { accountMap, failed }; +}; + +/** + * 顺序翻页扫描主创评论,命中或没有下一页时停止 + * @param songId - 网易云歌曲 id + * @param type - 评论分类 + * @param maxPages - 最多翻页数 + * @param accountMap - 账号 id → 歌手名 + * @returns 命中的主创评论 + */ +const scanPages = async ( + songId: string, + type: "hot" | "new", + maxPages: number, + accountMap: Map, +): Promise => { + const hits: MusicCommentItem[] = []; + for (let page = 1; page <= maxPages; page++) { + const data = await fetchPage(songId, type, page); + hits.push(...scanCreatorComments(data.list, accountMap)); + if (hits.length > 0 || data.hasMore !== true) break; + } + return hits; +}; + +/** + * 获取主创说评论:歌曲关联歌手在网易云绑定的账号在该曲下的发言 + * 先扫热评,热评无命中时才扫普通评论,两个阶段都顺序按需翻页并命中即停 + * @param args - 评论源与曲目 + * @returns 按点赞数降序的主创评论 + */ +export const getCreatorComments = async ( + args: MusicCommentCreatorQuery, +): Promise => { + if (args.sourceId !== NETEASE_SOURCE_ID) return []; + const meta = await findNeteaseSongMeta(args.track); + if (!meta) return []; + + const cached = creatorCommentsCache.get(meta.songId); + if (cached !== undefined) return cached; + + return coalesce(creatorInflight, meta.songId, async () => { + const { accountMap, failed } = await resolveArtistAccountIds(meta.artistIds); + if (accountMap.size === 0) { + if (!failed) creatorCommentsCache.set(meta.songId, []); + return []; + } + + let result = await scanPages(meta.songId, "hot", MAX_HOT_PAGES, accountMap); + if (result.length === 0) { + result = await scanPages(meta.songId, "new", MAX_NEW_PAGES, accountMap); + } + + const seen = new Set(); + result = result.filter((item) => (seen.has(item.id) ? false : seen.add(item.id))); + result.sort((a, b) => (b.likedCount ?? -1) - (a.likedCount ?? -1)); + if (!failed) creatorCommentsCache.set(meta.songId, result); + return result; + }); +}; diff --git a/electron/main/services/comments/data.spec.ts b/electron/main/services/comments/data.spec.ts new file mode 100644 index 000000000..84578421e --- /dev/null +++ b/electron/main/services/comments/data.spec.ts @@ -0,0 +1,144 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import type { MusicCommentItem } from "@shared/types/comment"; +import { + normalizeNeteaseComment, + normalizeNeteaseCommentPage, + normalizeQQMusicCommentPage, + scanCreatorComments, +} from "./data"; + +const comment = (overrides: Record = {}) => ({ + commentId: 1, + content: "内容", + user: { userId: 9, nickname: "听众" }, + ...overrides, +}); + +describe("网易云评论归一化", () => { + it("top-level more 决定 hasMore", () => { + const page = normalizeNeteaseCommentPage( + { comments: [comment()], total: 100, more: true }, + "new", + 1, + 20, + ); + expect(page.hasMore).toBe(true); + }); + + it("回落到 data.hasMore", () => { + const page = normalizeNeteaseCommentPage( + { data: { comments: [comment()], hasMore: true } }, + "new", + 1, + 20, + ); + expect(page.hasMore).toBe(true); + }); + + it("两者都缺失时 hasMore 为 undefined", () => { + const page = normalizeNeteaseCommentPage({ comments: [comment()] }, "new", 1, 20); + expect(page.hasMore).toBeUndefined(); + }); + + it("total 缺失时回退 0", () => { + const page = normalizeNeteaseCommentPage({ comments: [comment()] }, "new", 1, 20); + expect(page.total).toBe(0); + }); + + it("回复缺少自身 id 时用 fallbackId 生成互不相同的 id", () => { + const item = normalizeNeteaseComment({ + commentId: 7, + content: "父评论", + user: { userId: 1, nickname: "甲" }, + beReplied: [ + { content: "回复一", user: { userId: 2, nickname: "乙" } }, + { content: "回复二", user: { userId: 3, nickname: "丙" } }, + ], + }); + const ids = item?.reply?.map((reply) => reply.id) ?? []; + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(2); + }); +}); + +describe("QQ 音乐评论归一化", () => { + it("透传 hasMore 并在为真时保留空游标", () => { + const page = normalizeQQMusicCommentPage( + { comments: [{ CmId: "1", Content: "内容", Nick: "听众" }], hasMore: true }, + 1, + 20, + ); + expect(page.hasMore).toBe(true); + expect(page.nextCursor).toBe(""); + }); + + it("hasMore 为假时省略游标", () => { + const page = normalizeQQMusicCommentPage( + { comments: [{ CmId: "1", Content: "内容", Nick: "听众" }], hasMore: false }, + 1, + 20, + ); + expect(page.hasMore).toBe(false); + expect(page.nextCursor).toBeUndefined(); + }); +}); + +describe("主创评论扫描", () => { + const accountMap = new Map([["9", "主创名"]]); + + it("命中顶层评论并写入 creatorName", () => { + const items: MusicCommentItem[] = [{ id: "1", userId: "9", userName: "a", text: "t" }]; + const hits = scanCreatorComments(items, accountMap); + expect(hits).toHaveLength(1); + expect(hits[0]?.creatorName).toBe("主创名"); + }); + + it("主创在粉丝评论下的回复同样计入", () => { + const items: MusicCommentItem[] = [ + { + id: "1", + userId: "100", + userName: "粉丝", + text: "问", + reply: [{ id: "2", userId: "9", userName: "主创", text: "答" }], + }, + ]; + const hits = scanCreatorComments(items, accountMap); + expect(hits).toHaveLength(1); + expect(hits[0]?.id).toBe("2"); + expect(hits[0]?.creatorName).toBe("主创名"); + }); + + it("顶层主创评论下的其他主创回复也会返回", () => { + const items: MusicCommentItem[] = [ + { + id: "1", + userId: "9", + userName: "主创甲", + text: "顶层", + reply: [{ id: "2", userId: "10", userName: "主创乙", text: "回复" }], + }, + ]; + const hits = scanCreatorComments( + items, + new Map([ + ["9", "主创甲"], + ["10", "主创乙"], + ]), + ); + expect(hits.map((hit) => hit.id)).toEqual(["1", "2"]); + expect(hits.map((hit) => hit.creatorName)).toEqual(["主创甲", "主创乙"]); + }); + + it("歌手名为空时 creatorName 为空串", () => { + const items: MusicCommentItem[] = [{ id: "1", userId: "9", userName: "a", text: "t" }]; + const hits = scanCreatorComments(items, new Map([["9", ""]])); + expect(hits[0]?.creatorName).toBe(""); + }); + + it("账号映射为空时返回空数组", () => { + const items: MusicCommentItem[] = [{ id: "1", userId: "9", userName: "a", text: "t" }]; + expect(scanCreatorComments(items, new Map())).toEqual([]); + }); +}); diff --git a/electron/main/services/comments/data.ts b/electron/main/services/comments/data.ts index 85f1d5ac7..481a8bc39 100644 --- a/electron/main/services/comments/data.ts +++ b/electron/main/services/comments/data.ts @@ -28,10 +28,13 @@ interface NeteaseComment { replyCount?: number; } -interface NeteaseCommentBody { +export interface NeteaseCommentBody { total?: number; hotComments?: NeteaseComment[]; comments?: NeteaseComment[]; + /** legacy 评论端点用顶层 more 表示还有下一页 */ + more?: boolean; + hasMore?: boolean; data?: { totalCount?: number; comments?: NeteaseComment[]; @@ -98,21 +101,31 @@ const toStringId = (value: unknown): string => { return ""; }; -const optionalString = (value: unknown): string | undefined => { +export const optionalString = (value: unknown): string | undefined => { if (typeof value !== "string") return undefined; const text = value.trim(); return text || undefined; }; -/** 转换 NCM 评论项 */ -export const normalizeNeteaseComment = (raw: NeteaseComment): MusicCommentItem | null => { - const id = toStringId(raw.commentId ?? raw.beRepliedCommentId); +/** + * 转换 NCM 评论项 + * @param raw - 原始评论数据 + * @param fallbackId - 回复项缺少自身 id 时的兜底,避免多条回复共享父 id + * @returns 归一化评论,缺少 id 或正文时返回 null + */ +export const normalizeNeteaseComment = ( + raw: NeteaseComment, + fallbackId?: { parentId: string; index: number }, +): MusicCommentItem | null => { + const id = + toStringId(raw.commentId ?? raw.beRepliedCommentId) || + (fallbackId ? `${fallbackId.parentId}_reply_${fallbackId.index}` : ""); const text = optionalString(raw.content); if (!id || !text) return null; const userId = toStringId(raw.user?.userId); const reply = (raw.beReplied ?? []) - .map((item) => normalizeNeteaseComment(item)) + .map((item, index) => normalizeNeteaseComment(item, { parentId: id, index })) .filter((item): item is MusicCommentItem => item !== null); const item: MusicCommentItem = { @@ -132,7 +145,14 @@ export const normalizeNeteaseComment = (raw: NeteaseComment): MusicCommentItem | return item; }; -/** 转换 NCM 评论分页 */ +/** + * 转换 NCM 评论分页 + * @param body - 原始响应正文 + * @param type - 评论分类 + * @param page - 页码 + * @param limit - 每页数量 + * @returns 归一化分页,total 缺失时以 0 表示未知 + */ export const normalizeNeteaseCommentPage = ( body: NeteaseCommentBody, type: CommentTab, @@ -146,12 +166,15 @@ export const normalizeNeteaseCommentPage = ( const list = rawList .map((item) => normalizeNeteaseComment(item)) .filter((item): item is MusicCommentItem => item !== null); + // legacy 端点用顶层 more,新端点给出 data.hasMore + const hasMore = body.more ?? body.hasMore ?? body.data?.hasMore; return { list, - total: body.total ?? body.data?.totalCount ?? list.length, + total: body.total ?? body.data?.totalCount ?? 0, page, limit, + ...(typeof hasMore === "boolean" ? { hasMore } : {}), }; }; @@ -184,7 +207,13 @@ export const normalizeQQMusicComment = (raw: QQMusicComment): MusicCommentItem | return item; }; -/** 转换 QM 评论分页 */ +/** + * 转换 QM 评论分页 + * @param body - 原始响应正文 + * @param page - 页码 + * @param limit - 每页数量 + * @returns 归一化分页,仍有下一页时保留游标(允许空串) + */ export const normalizeQQMusicCommentPage = ( body: QQMusicCommentBody, page: number, @@ -193,12 +222,14 @@ export const normalizeQQMusicCommentPage = ( const list = (body.comments ?? []) .map((item) => normalizeQQMusicComment(item)) .filter((item): item is MusicCommentItem => item !== null); + const hasMore = typeof body.hasMore === "boolean" ? body.hasMore : undefined; return { list, - total: body.total ?? list.length, + total: body.total ?? 0, page, limit, - ...(body.hasMore && body.nextCursor ? { nextCursor: body.nextCursor } : {}), + ...(hasMore === undefined ? {} : { hasMore }), + ...(hasMore ? { nextCursor: body.nextCursor ?? "" } : {}), }; }; @@ -245,12 +276,38 @@ export const normalizeKugouCommentPage = ( .filter((item): item is MusicCommentItem => item !== null); return { list, - total: data.count ?? list.length, + total: data.count ?? 0, page: data.current_page ?? page, limit, }; }; +/** + * 从已归一化的评论中筛出主创评论,主创在粉丝评论下的回复同样计入 + * @param items - 已归一化的评论列表 + * @param accountMap - 网易云账号 id → 歌手名 + * @returns 命中的主创评论,creatorName 为对应歌手名 + */ +export const scanCreatorComments = ( + items: MusicCommentItem[], + accountMap: Map, +): MusicCommentItem[] => { + if (accountMap.size === 0) return []; + const result: MusicCommentItem[] = []; + const push = (item: MusicCommentItem, userId: string): void => { + result.push({ ...item, creatorName: accountMap.get(userId) ?? "" }); + }; + for (const item of items) { + const userId = item.userId; + if (userId && accountMap.has(userId)) push(item, userId); + for (const reply of item.reply ?? []) { + const replyUserId = reply.userId; + if (replyUserId && accountMap.has(replyUserId)) push(reply, replyUserId); + } + } + return result; +}; + /** 构建可用评论源 */ export const buildCommentSources = ( plugins: Array< diff --git a/electron/main/services/comments/index.ts b/electron/main/services/comments/index.ts index 76895c136..4febaf74b 100644 --- a/electron/main/services/comments/index.ts +++ b/electron/main/services/comments/index.ts @@ -9,6 +9,7 @@ import { pluginLog } from "@main/utils/logger"; import type { CommentSource, MusicCommentPage, MusicCommentQuery } from "@shared/types/comment"; import type { MusicSearchCandidate } from "@shared/types/plugin"; import type { Track } from "@shared/types/player"; +import { BoundedMap, coalesce } from "./cache"; import { buildCommentSources, normalizeNeteaseCommentPage, @@ -23,6 +24,21 @@ const QQMUSIC_SOURCE_ID = "builtin:qqmusic"; const KUGOU_SOURCE_ID = "builtin:kugou"; const NETEASE_RESOURCE_TYPE = "R_SO_4_"; +const SONG_META_CACHE_LIMIT = 200; + +/** 网易云歌曲元信息 */ +export type NeteaseSongMeta = { songId: string; artistIds: string[] }; + +/** 曲目 key → 网易云歌曲元信息(null 表示负缓存),分页与主创说复用同一次搜索 */ +const songMetaCache = new BoundedMap(SONG_META_CACHE_LIMIT); +/** 元信息解析在途请求 */ +const songMetaInflight = new Map>(); +/** QQ / 酷狗 id 解析在途请求,避免两个 tab 同时搜索同一曲目 */ +const trackIdInflight = new Map>(); + +const makeTrackKey = (track: Track): string => + `${track.source}\n${track.serverId ?? ""}\n${track.id}`; + const PLATFORM_TO_PLUGIN_SOURCE: Record = { netease: "wy", qqmusic: "tx", @@ -80,46 +96,73 @@ const findPluginMatch = async ( return pickBestCandidate(candidates, track)?.extra ?? null; }; -const findNeteaseId = async (track: Track): Promise => { - if (track.source === "netease" && track.id) return track.id; - const keyword = toKeyword(track); - if (!keyword) return null; - const { status, body } = await callNetease("search", { - keywords: keyword, - type: 1, - limit: 20, +/** + * 解析曲目对应的网易云歌曲元信息 + * 曲目本身来自网易云时直接取自身信息,其余来源走一次搜索同时取歌曲 id 与歌手 id + * @param track - 待解析曲目 + * @returns 歌曲元信息,无匹配结果时返回 null + */ +export const findNeteaseSongMeta = async (track: Track): Promise => { + const cacheKey = makeTrackKey(track); + const cached = songMetaCache.get(cacheKey); + if (cached !== undefined) return cached; + if (track.source === "netease" && track.id) { + const meta: NeteaseSongMeta = { + songId: track.id, + artistIds: track.artists + .map((artist) => artist.id) + .filter((id): id is string => Boolean(id) && id !== "undefined"), + }; + songMetaCache.set(cacheKey, meta); + return meta; + } + return coalesce(songMetaInflight, cacheKey, async () => { + const keyword = toKeyword(track); + if (!keyword) return null; + const { status, body } = await callNetease("search", { + keywords: keyword, + type: 1, + limit: 20, + }); + if (status !== 200) return null; + const songs = body.result?.songs ?? []; + const candidates: LyricCandidate[] = songs.map( + (song: { + id: string | number; + name?: string; + artists?: { id?: string | number; name: string }[]; + album?: { name?: string }; + duration?: number; + }) => ({ + name: song.name ?? "", + artist: (song.artists ?? []).map((artist) => artist.name).join(" / "), + album: song.album?.name, + duration: song.duration, + extra: { + songId: String(song.id), + artistIds: (song.artists ?? []) + .map((artist) => (artist.id ? String(artist.id) : "")) + .filter(Boolean), + }, + }), + ); + const meta = pickBestCandidate(candidates, track)?.extra ?? null; + songMetaCache.set(cacheKey, meta); + return meta; }); - if (status !== 200) return null; - const songs = body.result?.songs ?? []; - const candidates: LyricCandidate<{ id: string }>[] = songs.map( - (song: { - id: string | number; - name?: string; - artists?: { name: string }[]; - album?: { name?: string }; - duration?: number; - }) => ({ - name: song.name ?? "", - artist: (song.artists ?? []).map((artist) => artist.name).join(" / "), - album: song.album?.name, - duration: song.duration, - extra: { id: String(song.id) }, - }), - ); - return pickBestCandidate(candidates, track)?.extra.id ?? null; }; const getNeteaseComments = async (args: MusicCommentQuery): Promise => { - const id = await findNeteaseId(args.track); - if (!id) return { list: [], total: 0, page: args.page, limit: args.limit }; - + const meta = await findNeteaseSongMeta(args.track); + if (!meta) return { list: [], total: 0, page: args.page, limit: args.limit, hasMore: false }; const apiName = args.type === "hot" ? "comment_hot" : "comment_music"; - const { body } = await callNetease(apiName, { - id, + const { status, body } = await callNetease(apiName, { + id: meta.songId, type: NETEASE_RESOURCE_TYPE, limit: args.limit, offset: (args.page - 1) * args.limit, }); + if (status !== 200) throw new Error(`netease comment request failed: ${status}`); return normalizeNeteaseCommentPage(body, args.type, args.page, args.limit); }; @@ -129,22 +172,30 @@ const findQQMusicId = async (track: Track): Promise => { } const keyword = toKeyword(track); if (!keyword) return null; - const body = await callQQMusic("search", { keywords: keyword, type: 0, page: 1, limit: 20 }); - const candidates: LyricCandidate<{ id: string }>[] = (body.songs ?? []).map( - (song: { id?: string; name?: string; artist?: string; album?: string; duration?: number }) => ({ - name: song.name ?? "", - artist: song.artist ?? "", - album: song.album, - duration: song.duration, - extra: { id: song.id ?? "" }, - }), - ); - return pickBestCandidate(candidates, track)?.extra.id || null; + return coalesce(trackIdInflight, `qqmusic\n${makeTrackKey(track)}`, async () => { + const body = await callQQMusic("search", { keywords: keyword, type: 0, page: 1, limit: 20 }); + const candidates: LyricCandidate<{ id: string }>[] = (body.songs ?? []).map( + (song: { + id?: string; + name?: string; + artist?: string; + album?: string; + duration?: number; + }) => ({ + name: song.name ?? "", + artist: song.artist ?? "", + album: song.album, + duration: song.duration, + extra: { id: song.id ?? "" }, + }), + ); + return pickBestCandidate(candidates, track)?.extra.id || null; + }); }; const getQQMusicComments = async (args: MusicCommentQuery): Promise => { const id = await findQQMusicId(args.track); - if (!id) return { list: [], total: 0, page: args.page, limit: args.limit }; + if (!id) return { list: [], total: 0, page: args.page, limit: args.limit, hasMore: false }; const body = await callQQMusic("comment", { id, type: args.type, @@ -159,26 +210,29 @@ const findKugouId = async (track: Track): Promise => { if (track.source === "kugou" && track.extId) return track.extId; const keyword = toKeyword(track); if (!keyword) return null; - const body = await callKugou<{ songs?: KGSong[] }>("search", { - keywords: keyword, - type: 0, - page: 1, - limit: 20, + return coalesce(trackIdInflight, `kugou\n${makeTrackKey(track)}`, async () => { + const body = await callKugou<{ songs?: KGSong[] }>("search", { + keywords: keyword, + type: 0, + page: 1, + limit: 20, + }); + const candidates: LyricCandidate<{ id: string }>[] = (body.songs ?? []).map((song) => ({ + name: song.name, + artist: song.artist, + album: song.album, + duration: song.duration, + extra: { id: song.albumAudioId ? String(song.albumAudioId) : "" }, + })); + return pickBestCandidate(candidates, track)?.extra.id || null; }); - const candidates: LyricCandidate<{ id: string }>[] = (body.songs ?? []).map((song) => ({ - name: song.name, - artist: song.artist, - album: song.album, - duration: song.duration, - extra: { id: song.albumAudioId ? String(song.albumAudioId) : "" }, - })); - return pickBestCandidate(candidates, track)?.extra.id || null; }; const getKugouComments = async (args: MusicCommentQuery): Promise => { - if (args.type !== "hot") return { list: [], total: 0, page: args.page, limit: args.limit }; + if (args.type !== "hot") + return { list: [], total: 0, page: args.page, limit: args.limit, hasMore: false }; const id = await findKugouId(args.track); - if (!id) return { list: [], total: 0, page: args.page, limit: args.limit }; + if (!id) return { list: [], total: 0, page: args.page, limit: args.limit, hasMore: false }; const body = await callKugou("comment", { id, page: args.page, @@ -195,7 +249,8 @@ const getPluginComments = async ( if (!rt || rt.status.state !== "ready") throw new Error("plugin comment source is not ready"); try { const musicInfo = await findPluginMatch(rt, parsed.source, args.track); - if (!musicInfo) return { list: [], total: 0, page: args.page, limit: args.limit }; + if (!musicInfo) + return { list: [], total: 0, page: args.page, limit: args.limit, hasMore: false }; return await callMusicComment(rt, { source: parsed.source, musicInfo, diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c4acf4dbb..fc207f881 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -24,7 +24,7 @@ import type { PlayEventInput, FavoriteEventInput } from "@shared/types/stats"; import type { TagEditRequest } from "@shared/types/tagEditor"; import type { UpdateEvent } from "@shared/types/update"; import type { CloudUploadProgress } from "@shared/types/cloudUpload"; -import type { MusicCommentQuery } from "@shared/types/comment"; +import type { MusicCommentCreatorQuery, MusicCommentQuery } from "@shared/types/comment"; import type { AiModelSaveInput } from "@shared/types/ai"; import type { DesktopLyricUnlockButtonBounds } from "@shared/types/window"; import type { @@ -532,6 +532,7 @@ const api = { comments: { sources: () => ipcRenderer.invoke("comments:sources"), get: (args: MusicCommentQuery) => ipcRenderer.invoke("comments:get", args), + creator: (args: MusicCommentCreatorQuery) => ipcRenderer.invoke("comments:creator", args), }, download: { // 入队下载 diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index adf5cb641..45869c495 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -136,6 +136,10 @@ export const defaultSystemConfig: SystemConfig = { nowPlaying: true, loveSync: true, }, + comments: { + creatorEnabled: false, + density: "standard", + }, externalApi: { enabled: false, wsEnabled: false, diff --git a/shared/types/comment.ts b/shared/types/comment.ts index 671f3fbcd..2b82a02b1 100644 --- a/shared/types/comment.ts +++ b/shared/types/comment.ts @@ -33,6 +33,8 @@ export interface MusicCommentItem { replyTotal?: number; reply?: MusicCommentItem[]; hasMoreReply?: boolean; + /** 主创评论对应的歌手名,仅主创说结果携带 */ + creatorName?: string; } /** 歌曲评论分页 */ @@ -43,6 +45,8 @@ export interface MusicCommentPage { limit: number; /** 下一页游标 */ nextCursor?: string; + /** 是否还有下一页,由各来源的原生信号给出 */ + hasMore?: boolean; } /** 评论查询参数 */ @@ -60,8 +64,19 @@ export interface MusicCommentQuery { export type MusicCommentResponse = { ok: true; data: MusicCommentPage } | { ok: false; error: string }; +/** 主创说查询参数 */ +export interface MusicCommentCreatorQuery { + sourceId: string; + track: Track; +} + +/** 主创说 IPC 响应 */ +export type MusicCommentCreatorResponse = + { ok: true; data: MusicCommentItem[] } | { ok: false; error: string }; + /** 渲染端评论 API */ export interface CommentsApi { sources: () => Promise; get: (args: MusicCommentQuery) => Promise; + creator: (args: MusicCommentCreatorQuery) => Promise; } diff --git a/shared/types/settings.ts b/shared/types/settings.ts index 190f1418f..0b231cb2b 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -232,6 +232,17 @@ export interface StreamingSettings { enabled: boolean; } +/** 评论卡片密度 */ +export type CommentDensity = "compact" | "standard" | "comfortable"; + +/** 评论设置 */ +export interface CommentSettings { + /** 在评论区展示主创说 */ + creatorEnabled: boolean; + /** 播放器内评论卡片密度 */ + density: CommentDensity; +} + /** 外部 API 服务配置 */ export interface ExternalApiSettings { /** 总开关 */ @@ -465,6 +476,8 @@ export interface SystemConfig { streaming: StreamingSettings; /** Last.fm 集成配置 */ lastfm: LastfmSettings; + /** 评论设置 */ + comments: CommentSettings; /** 外部 API 服务(HTTP + WS) */ externalApi: ExternalApiSettings; /** AI 集成使用的 MCP 服务 */ diff --git a/src/components/comments/CommentCard.vue b/src/components/comments/CommentCard.vue new file mode 100644 index 000000000..a5a327d0b --- /dev/null +++ b/src/components/comments/CommentCard.vue @@ -0,0 +1,153 @@ + + + diff --git a/src/components/comments/CommentList.vue b/src/components/comments/CommentList.vue new file mode 100644 index 000000000..a9503aa1a --- /dev/null +++ b/src/components/comments/CommentList.vue @@ -0,0 +1,177 @@ + + + diff --git a/src/components/player/FullPlayer/FullPlayerComments.vue b/src/components/player/FullPlayer/FullPlayerComments.vue new file mode 100644 index 000000000..8fa385c59 --- /dev/null +++ b/src/components/player/FullPlayer/FullPlayerComments.vue @@ -0,0 +1,262 @@ + + + diff --git a/src/components/player/FullPlayer/index.vue b/src/components/player/FullPlayer/index.vue index 88b34bfc1..afae7f41a 100644 --- a/src/components/player/FullPlayer/index.vue +++ b/src/components/player/FullPlayer/index.vue @@ -12,6 +12,7 @@ import { useImmersiveMode } from "@/composables/useImmersiveMode"; import { useTimeFormat } from "@/composables/useTimeFormat"; import { useProgressLyric } from "@/composables/useProgressLyric"; import Lyrics from "@/components/player/Lyrics/index.vue"; +import FullPlayerComments from "./FullPlayerComments.vue"; import { useWindowControls } from "@/composables/useWindowControls"; import * as player from "@/core/player"; import { playerTransitionActive, setPlayerExpanded } from "@/services/playerTransition"; @@ -191,10 +192,17 @@ const fullscreenCover = computed(() => settings.player.coverLayout === "fullscre const coverWidth = computed(() => `${settings.player.coverLyricRatio * 100}%`); const coverCentered = computed(() => { - if (fullscreenCover.value || status.fullQueueOpen) return false; + if (fullscreenCover.value || status.fullQueueOpen || status.fullCommentsOpen) return false; return !showLyric.value || (settings.player.autoCenterCover && !hasLyric.value); }); +/** 全屏内嵌评论布局:off 关闭、half 左半屏、full 全屏 */ +const fullCommentMode = computed<"off" | "half" | "full">(() => { + if (!status.fullCommentsOpen) return "off"; + // 以实际就绪的歌词判定,避免歌词加载中导致面板宽度反复切换 + return media.parsedLyric.length > 0 && showLyric.value ? "half" : "full"; +}); + const handleLyricSeek = async (timeMs: number): Promise => { await player.seek(timeMs); if (!isPlaying.value) await player.play(); @@ -259,10 +267,6 @@ const toggleLyric = (): void => { showLyric.value = !showLyric.value; } }; - -const showComments = (): void => { - if (displayTrack.value) status.showComments(displayTrack.value); -};