Skip to content
Open
4 changes: 4 additions & 0 deletions components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -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']
Expand Down
22 changes: 21 additions & 1 deletion electron/main/ipc/comments.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -18,4 +25,17 @@ export const registerCommentsIpc = (): void => {
}
},
);

ipcMain.handle(
"comments:creator",
async (_evt, args: MusicCommentCreatorQuery): Promise<MusicCommentCreatorResponse> => {
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) };
}
},
);
};
83 changes: 83 additions & 0 deletions electron/main/services/comments/cache.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | null>(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<string, string>(2, 1000);
cache.set("a", "a");
vi.advanceTimersByTime(1001);
expect(cache.get("a")).toBeUndefined();
});

it("包装后能区分缺失与存了 null", () => {
const cache = new BoundedMap<string, string | null>(2);
cache.set("a", null);
expect(cache.get("a")).toBeNull();
expect(cache.get("b")).toBeUndefined();
});

it("超过上限时淘汰最久未使用的条目", () => {
const cache = new BoundedMap<string, number>(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<string, Promise<number>>();
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<string, Promise<number>>();
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<string, Promise<string>>();
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);
});
});
81 changes: 81 additions & 0 deletions electron/main/services/comments/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/** 单条缓存记录,包装后 get 的 undefined 才能唯一表示「不存在」 */
interface CacheEntry<V> {
value: V;
/** 过期时间戳(毫秒),0 表示永不过期 */
expireAt: number;
}

/**
* 有界 LRU 缓存,可选用 TTL
* 以条目包装存储,负缓存(null / 空数组)同样参与访问提升与淘汰
*/
export class BoundedMap<K, V> {
private readonly map = new Map<K, CacheEntry<V>>();

/**
* @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 = <K, T>(
inflight: Map<K, Promise<T>>,
key: K,
factory: () => Promise<T>,
): Promise<T> => {
const pending = inflight.get(key);
if (pending) return pending;
const promise = factory().finally(() => {
inflight.delete(key);
});
inflight.set(key, promise);
return promise;
};
Loading