Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion electron/main/ipc/comments.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { ipcMain } from "electron";
import { getCommentSources, getMusicComments } from "@main/services/comments";
import { getCreatorComments } from "@main/services/comments/creator";
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 => {
Expand All @@ -18,4 +24,16 @@ export const registerCommentsIpc = (): void => {
}
},
);

ipcMain.handle(
"comments:creator",
async (_evt, args: MusicCommentCreatorQuery): Promise<MusicCommentCreatorResponse> => {
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) };
}
},
);
};
145 changes: 145 additions & 0 deletions electron/main/services/comments/creator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { callNetease } from "@main/apis/netease";
import { coreLog } from "@main/utils/logger";
import type { MusicCommentCreatorQuery, MusicCommentItem } from "@shared/types/comment";
import { normalizeNeteaseCommentPage, scanCreatorComments } from "./data";
import { findNeteaseSongMeta } from "./index";

const NETEASE_SOURCE_ID = "builtin:netease";
const NETEASE_RESOURCE_TYPE = "R_SO_4_";
const HOT_LIMIT = 20;
/** 首屏前两页未命中时,最多额外翻页数 */
const MAX_EXTRA_PAGES = 3;
const ARTIST_ACCOUNT_CACHE_LIMIT = 200;
const CREATOR_COMMENTS_CACHE_LIMIT = 100;

/**
* 简易有界 LRU Map
* Map 保持插入顺序,访问时移到末尾,超限时删除首项
*/
class BoundedMap<K, V> {
private readonly map = new Map<K, V>();
constructor(private readonly limit: number) {}

has(key: K): boolean {
return this.map.has(key);
}

get(key: K): V | undefined {
const v = this.map.get(key);
if (v !== undefined && this.map.size > 1) {
this.map.delete(key);
this.map.set(key, v);
}
return v;
}

set(key: K, value: V): void {
if (this.map.has(key)) this.map.delete(key);
this.map.set(key, value);
while (this.map.size > this.limit) {
const first = this.map.keys().next().value;
if (first === undefined) break;
this.map.delete(first);
}
}
}

/** artist id → 绑定的网易云用户 accountId(null 表示负缓存:未绑定) */
const artistAccountIdCache = new BoundedMap<string, string | null>(ARTIST_ACCOUNT_CACHE_LIMIT);
/** 网易云 songId → 主创评论列表(空数组表示负缓存:无主创评论) */
const creatorCommentsCache = new BoundedMap<string, MusicCommentItem[]>(
CREATOR_COMMENTS_CACHE_LIMIT,
);

/** 拉取一页热门评论(归一化后返回) */
const fetchHotPage = async (
songId: string,
page: number,
): Promise<{ items: MusicCommentItem[]; hasMore: boolean }> => {
const { body } = await callNetease("comment_hot", {
id: songId,
type: NETEASE_RESOURCE_TYPE,
limit: HOT_LIMIT,
offset: (page - 1) * HOT_LIMIT,
});
const normalized = normalizeNeteaseCommentPage(body, "hot", page, HOT_LIMIT);
const hasMore = Boolean(body?.hasMore ?? body?.data?.hasMore ?? false);
return { items: normalized.list, hasMore };
};

/**
* 解析歌手列表对应的网易云用户 accountId 集合
* 多歌手多账号匹配并去重;accountId 为 0 或不存在视为未绑定(负缓存)
*/
const resolveArtistAccountIds = async (artistIds: string[]): Promise<Set<string>> => {
const accountIds = new Set<string>();
for (const artistId of artistIds) {
if (!artistId) continue;
const cached = artistAccountIdCache.get(artistId);
if (cached !== undefined) {
if (cached) accountIds.add(cached);
continue;
}
try {
const { body } = await callNetease("artists", { id: artistId });
const raw = body?.artist?.accountId;
const normalized =
typeof raw === "number" && raw > 0
? String(raw)
: typeof raw === "string" && raw
? raw
: null;
artistAccountIdCache.set(artistId, normalized);
if (normalized) accountIds.add(normalized);
} catch (err) {
coreLog.warn(`[comments] resolve artist ${artistId} accountId failed:`, err);
artistAccountIdCache.set(artistId, null);
}
}
return accountIds;
};

/**
* 获取「主创说」评论:歌曲关联歌手在网易云绑定的账号,在该歌曲热门评论中的发言
* 仅网易云内建源启用;前两页必拉,未命中且 hasMore 时额外翻页最多 MAX_EXTRA_PAGES 页
*/
export const getCreatorComments = async (
args: MusicCommentCreatorQuery,
): Promise<MusicCommentItem[]> => {
if (args.sourceId !== NETEASE_SOURCE_ID) return [];

const meta = await findNeteaseSongMeta(args.track);
if (!meta) return [];
const { songId, artistIds } = meta;

const cached = creatorCommentsCache.get(songId);
if (cached) return cached;

const accountIds = await resolveArtistAccountIds(artistIds);
if (accountIds.size === 0) {
creatorCommentsCache.set(songId, []);
return [];
}

const result: MusicCommentItem[] = [];
let hasMore = true;
// 前两页必拉,收集全部命中
for (let page = 1; page <= 2 && hasMore; page++) {
const { items, hasMore: hm } = await fetchHotPage(songId, page);
result.push(...scanCreatorComments(items, accountIds));
hasMore = hm;
}
// 前两页无命中 + 还有更多 → 额外翻页,命中即停
if (result.length === 0 && hasMore) {
for (let extra = 1; extra <= MAX_EXTRA_PAGES && hasMore; extra++) {
const { items, hasMore: hm } = await fetchHotPage(songId, 2 + extra);
const hits = scanCreatorComments(items, accountIds);
result.push(...hits);
hasMore = hm;
if (hits.length > 0) break;
}
}

creatorCommentsCache.set(songId, result);
return result;
};
9 changes: 9 additions & 0 deletions electron/main/services/comments/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ export const normalizeNeteaseCommentPage = (
};
};

/** 纯函数:从已归一化的评论中筛选主创评论(userId 命中账号集合) */
export const scanCreatorComments = (
items: MusicCommentItem[],
accountIds: Set<string>,
): MusicCommentItem[] => {
if (accountIds.size === 0) return [];
return items.filter((item) => item.userId != null && accountIds.has(item.userId));
};

/** 构建可用评论源 */
export const buildCommentSources = (
plugins: Array<
Expand Down
34 changes: 28 additions & 6 deletions electron/main/services/comments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,19 @@ const findPluginMatch = async (
return pickBestCandidate(candidates, track)?.extra ?? null;
};

const findNeteaseId = async (track: Track): Promise<string | null> => {
if (track.source === "netease" && track.id) return track.id;
/**
* 解析 Track 对应的网易云歌曲元信息:songId + 歌手 id 列表
* 一次 search 同时取两者,避免主创说二次搜索
*/
export const findNeteaseSongMeta = async (
track: Track,
): Promise<{ songId: string; artistIds: string[] } | null> => {
if (track.source === "netease" && track.id) {
return {
songId: track.id,
artistIds: track.artists.map((a) => a.id).filter((id): id is string => !!id),
};
}
const keyword = toKeyword(track);
if (!keyword) return null;
const { status, body } = await callNetease("search", {
Expand All @@ -79,22 +90,33 @@ const findNeteaseId = async (track: Track): Promise<string | null> => {
});
if (status !== 200) return null;
const songs = body.result?.songs ?? [];
const candidates: LyricCandidate<{ id: string }>[] = songs.map(
const candidates: LyricCandidate<{ id: string; artistIds: string[] }>[] = songs.map(
(song: {
id: string | number;
name?: string;
artists?: { 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: { id: String(song.id) },
extra: {
id: String(song.id),
artistIds: (song.artists ?? []).map((a) => (a.id ? String(a.id) : "")).filter(Boolean),
},
}),
);
return pickBestCandidate(candidates, track)?.extra.id ?? null;
const best = pickBestCandidate(candidates, track)?.extra;
if (!best) return null;
return { songId: best.id, artistIds: best.artistIds };
};

/** 取网易云歌曲 id(薄包装,供普通评论拉取复用) */
const findNeteaseId = async (track: Track): Promise<string | null> => {
const meta = await findNeteaseSongMeta(track);
return meta?.songId ?? null;
};

const getNeteaseComments = async (args: MusicCommentQuery): Promise<MusicCommentPage> => {
Expand Down
3 changes: 2 additions & 1 deletion electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,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";

/** 订阅主进程推送的事件 */
Expand Down Expand Up @@ -369,6 +369,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: {
// 入队下载
Expand Down
12 changes: 12 additions & 0 deletions shared/types/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,20 @@ 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<CommentSource[]>;
get: (args: MusicCommentQuery) => Promise<MusicCommentResponse>;
creator: (args: MusicCommentCreatorQuery) => Promise<MusicCommentCreatorResponse>;
}
93 changes: 93 additions & 0 deletions src/components/comments/CommentCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<script setup lang="ts">
import type { MusicCommentItem } from "@shared/types/comment";
import { formatDate } from "@/utils/time";
import IconLucideThumbsUp from "~icons/lucide/thumbs-up";

interface Props {
/** 评论数据 */
item: MusicCommentItem;
/** 是否显示主创徽章 */
creator?: boolean;
/** 紧凑模式(缩减间距) */
compact?: boolean;
/** 是否使用全屏播放器配色 */
cover?: boolean;
}

withDefaults(defineProps<Props>(), {
creator: false,
compact: false,
cover: false,
});
</script>

<template>
<div
class="rounded-xl border border-solid px-3 py-2.5 transition-colors duration-150"
:class="
cover ? 'border-cover/10 bg-cover/6 hover:bg-cover/10' : 'border-primary/20 bg-surface-panel'
"
>
<div :class="compact ? 'flex gap-2.5' : 'flex gap-3'">
<SImg
v-if="item.avatar"
:src="item.avatar"
class="h-9 w-9 shrink-0 rounded-full ring-1"
:class="cover ? 'ring-cover/15' : 'ring-black/10 dark:ring-white/10'"
alt=""
/>
<div
v-else
class="h-9 w-9 shrink-0 rounded-full"
:class="cover ? 'bg-cover/10' : 'bg-on-surface/8'"
/>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<span class="truncate text-sm font-medium">{{ item.userName }}</span>
<STag v-if="creator" type="primary" variant="soft" size="tiny" round>
{{ $t("comments.creatorBadge") }}
</STag>
</div>
<div
class="mt-0.5 flex gap-2 text-xs"
:class="cover ? 'text-cover/50' : 'text-on-surface-variant'"
>
<span v-if="item.time">{{ formatDate(item.time) }}</span>
<span v-if="item.location">
{{ $t("comments.location", { location: item.location }) }}
</span>
</div>
</div>
<div
v-if="item.likedCount != null"
class="flex shrink-0 items-center gap-1 text-xs tabular-nums"
:class="cover ? 'text-cover/50' : 'text-on-surface-variant'"
>
<IconLucideThumbsUp class="size-3.5" />
<span>{{ item.likedCount }}</span>
</div>
</div>
<p class="mt-2 whitespace-pre-wrap break-words text-sm leading-6">{{ item.text }}</p>
<div
v-if="item.reply?.length"
class="mt-2 rounded-lg px-3 py-2"
:class="cover ? 'bg-cover/7' : 'bg-on-surface/5'"
>
<div
v-for="reply in item.reply"
:key="reply.id"
class="text-xs leading-5"
:class="cover ? 'text-cover/60' : 'text-on-surface-variant'"
>
<span class="font-medium" :class="cover ? 'text-cover/85' : 'text-on-surface'">
{{ reply.userName }}:
</span>
{{ reply.text }}
</div>
</div>
</div>
</div>
</div>
</template>
Loading