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
50 changes: 42 additions & 8 deletions electron/main/apis/common/lyric/kugou.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@

import { callKugou } from "@main/apis/kugou";
import { getCachedLyric, setCachedLyric } from "@main/database/lyricCache";
import { buildFingerprint, getMatchedId, setMatchedId } from "@main/database/lyricMatchCache";
import { buildFingerprint, getMatchedId } from "@main/database/lyricMatchCache";
import { coreLog } from "@main/utils/logger";
import type { LyricMatchResult } from "@shared/types/lyrics";
import type { LyricMatchQueryOptions, LyricMatchResult } from "@shared/types/lyrics";
import type { Track } from "@shared/types/player";
import { buildLyricSearchKeyword, pickBestCandidate, type LyricCandidate } from "./utils";

Expand Down Expand Up @@ -82,15 +82,36 @@ export const getByPlatformId = (hash: string): Promise<LyricMatchResult | null>
fetchLyric({ hash });

/** 按 Track 元数据模糊搜索:search → 挑最佳 → 单次请求歌词 */
export const getByQuery = async (track: Track): Promise<LyricMatchResult | null> => {
export const getByQuery = async (
track: Track,
options: LyricMatchQueryOptions = {},
): Promise<LyricMatchResult | null> => {
const fingerprint = buildFingerprint(track);
const cached = getMatchedId(fingerprint, "kugou");
if (cached) {
return fetchLyric({
if (
cached &&
options.validationKey &&
cached.extra?.validationKey === options.validationKey &&
!options.excludedIds?.includes(cached.platformId)
) {
const lyric = await fetchLyric({
hash: cached.platformId,
name: track.title,
durationMs: track.duration,
});
if (!lyric) return null;
return {
...lyric,
candidate: {
platformId: cached.platformId,
name: track.title,
artist: track.artists.map((artist) => artist.name).join(" / "),
album: track.album?.name,
duration: track.duration,
extra: cached.extra,
validated: true,
},
};
}

const keyword = buildLyricSearchKeyword(track);
Expand All @@ -114,15 +135,28 @@ export const getByQuery = async (track: Track): Promise<LyricMatchResult | null>
return null;
}

const best = pickBestCandidate(candidates, track);
const best = pickBestCandidate(
candidates.filter((candidate) => !options.excludedIds?.includes(candidate.extra.hash)),
track,
);
coreLog.info(
`[lyric:kugou] fuzzy "${keyword}" → ${candidates.length} hits, best=${best?.name ?? "none"}`,
);
if (!best) return null;
setMatchedId(fingerprint, "kugou", best.extra.hash);
return fetchLyric({
const lyric = await fetchLyric({
hash: best.extra.hash,
name: best.name,
durationMs: best.duration,
});
if (!lyric) return null;
return {
...lyric,
candidate: {
platformId: best.extra.hash,
name: best.name,
artist: best.artist,
album: best.album,
duration: best.duration,
},
};
};
50 changes: 43 additions & 7 deletions electron/main/apis/common/lyric/netease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@

import { callNetease } from "@main/apis/netease";
import { getCachedLyric, setCachedLyric } from "@main/database/lyricCache";
import { buildFingerprint, getMatchedId, setMatchedId } from "@main/database/lyricMatchCache";
import { buildFingerprint, getMatchedId } from "@main/database/lyricMatchCache";
import { coreLog } from "@main/utils/logger";
import type { LyricMatchResult } from "@shared/types/lyrics";
import type { LyricMatchQueryOptions, LyricMatchResult } from "@shared/types/lyrics";
import type { Track } from "@shared/types/player";
import { prefetchTTML } from "./ttml";
import { buildLyricSearchKeyword, pickBestCandidate, type LyricCandidate } from "./utils";
Expand Down Expand Up @@ -88,11 +88,34 @@ export const getByPlatformId = async (id: string): Promise<LyricMatchResult | nu
* 按 Track 元数据模糊搜索:search → 挑最佳 → 单次请求歌词
* @param track 歌曲信息
*/
export const getByQuery = async (track: Track): Promise<LyricMatchResult | null> => {
export const getByQuery = async (
track: Track,
options: LyricMatchQueryOptions = {},
): Promise<LyricMatchResult | null> => {
// 命中映射缓存:跳过 search → 直接走 byId
const fingerprint = buildFingerprint(track);
const cached = getMatchedId(fingerprint, "netease");
if (cached) return getByPlatformId(cached.platformId);
if (
cached &&
options.validationKey &&
cached.extra?.validationKey === options.validationKey &&
!options.excludedIds?.includes(cached.platformId)
) {
const lyric = await getByPlatformId(cached.platformId);
if (!lyric) return null;
return {
...lyric,
candidate: {
platformId: cached.platformId,
name: track.title,
artist: track.artists.map((artist) => artist.name).join(" / "),
album: track.album?.name,
duration: track.duration,
extra: cached.extra,
validated: true,
},
};
}

const keyword = buildLyricSearchKeyword(track);
if (!keyword) return null;
Expand All @@ -119,11 +142,24 @@ export const getByQuery = async (track: Track): Promise<LyricMatchResult | null>
coreLog.warn(`[lyric:netease] search("${keyword}") failed:`, err);
return null;
}
const best = pickBestCandidate(candidates, track);
const best = pickBestCandidate(
candidates.filter((candidate) => !options.excludedIds?.includes(candidate.extra.id)),
track,
);
coreLog.info(
`[lyric:netease] fuzzy "${keyword}" → ${candidates.length} hits, best=${best?.name ?? "none"}`,
);
if (!best) return null;
setMatchedId(fingerprint, "netease", best.extra.id);
return getByPlatformId(best.extra.id);
const lyric = await getByPlatformId(best.extra.id);
if (!lyric) return null;
return {
...lyric,
candidate: {
platformId: best.extra.id,
name: best.name,
artist: best.artist,
album: best.album,
duration: best.duration,
},
};
};
51 changes: 44 additions & 7 deletions electron/main/apis/common/lyric/qqmusic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@

import { callQQMusic } from "@main/apis/qqmusic";
import { getCachedLyric, setCachedLyric } from "@main/database/lyricCache";
import { buildFingerprint, getMatchedId, setMatchedId } from "@main/database/lyricMatchCache";
import { buildFingerprint, getMatchedId } from "@main/database/lyricMatchCache";
import { coreLog } from "@main/utils/logger";
import type { LyricMatchResult } from "@shared/types/lyrics";
import type { LyricMatchQueryOptions, LyricMatchResult } from "@shared/types/lyrics";
import type { Track } from "@shared/types/player";
import { prefetchTTML } from "./ttml";
import { buildLyricSearchKeyword, pickBestCandidate, type LyricCandidate } from "./utils";
Expand Down Expand Up @@ -78,10 +78,33 @@ export const getByPlatformId = async (
};

/** 按 Track 元数据模糊搜索:search → 挑最佳 → 单次请求歌词 */
export const getByQuery = async (track: Track): Promise<LyricMatchResult | null> => {
export const getByQuery = async (
track: Track,
options: LyricMatchQueryOptions = {},
): Promise<LyricMatchResult | null> => {
const fingerprint = buildFingerprint(track);
const cached = getMatchedId(fingerprint, "qqmusic");
if (cached) return getByPlatformId(cached.platformId, cached.extra?.mid);
if (
cached &&
options.validationKey &&
cached.extra?.validationKey === options.validationKey &&
!options.excludedIds?.includes(cached.platformId)
) {
const lyric = await getByPlatformId(cached.platformId, cached.extra?.mid);
if (!lyric) return null;
return {
...lyric,
candidate: {
platformId: cached.platformId,
name: track.title,
artist: track.artists.map((artist) => artist.name).join(" / "),
album: track.album?.name,
duration: track.duration,
extra: cached.extra,
validated: true,
},
};
}

const keyword = buildLyricSearchKeyword(track);
if (!keyword) return null;
Expand All @@ -104,11 +127,25 @@ export const getByQuery = async (track: Track): Promise<LyricMatchResult | null>
return null;
}

const best = pickBestCandidate(candidates, track);
const best = pickBestCandidate(
candidates.filter((candidate) => !options.excludedIds?.includes(candidate.extra.id)),
track,
);
coreLog.info(
`[lyric:qqmusic] fuzzy "${keyword}" → ${candidates.length} hits, best=${best?.name ?? "none"}`,
);
if (!best) return null;
setMatchedId(fingerprint, "qqmusic", best.extra.id, { mid: best.extra.mid });
return getByPlatformId(best.extra.id, best.extra.mid);
const lyric = await getByPlatformId(best.extra.id, best.extra.mid);
if (!lyric) return null;
return {
...lyric,
candidate: {
platformId: best.extra.id,
name: best.name,
artist: best.artist,
album: best.album,
duration: best.duration,
extra: { mid: best.extra.mid },
},
};
};
58 changes: 58 additions & 0 deletions electron/main/apis/common/lyric/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,64 @@ describe("pickBestCandidate", () => {

assert.equal(best?.extra.id, "fallback");
});

it("拒绝时长相差 42 秒的同名同歌手版本", () => {
const candidates: LyricCandidate<{ id: string }>[] = [
{
name: "同名歌曲",
artist: "目标歌手",
duration: 233_000,
extra: { id: "old-version" },
},
];

assert.equal(pickBestCandidate(candidates, track({ duration: 191_000 })), null);
});

it("拒绝标题版本标记不一致的候选", () => {
const candidates: LyricCandidate<{ id: string }>[] = [
{
name: "同名歌曲 Live",
artist: "目标歌手",
duration: 180_000,
extra: { id: "live" },
},
];

assert.equal(pickBestCandidate(candidates, track({})), null);
});

it("允许中英文等价的现场版标记", () => {
const candidates: LyricCandidate<{ id: string }>[] = [
{
name: "同名歌曲 Live",
artist: "目标歌手",
duration: 180_000,
extra: { id: "live" },
},
];

assert.equal(
pickBestCandidate(candidates, track({ title: "同名歌曲 现场" }))?.extra.id,
"live",
);
});

it("歌曲名恰好为版本关键字时保留原标题", () => {
const candidates: LyricCandidate<{ id: string }>[] = [
{
name: "Live",
artist: "目标歌手",
duration: 180_000,
extra: { id: "literal-live-title" },
},
];

assert.equal(
pickBestCandidate(candidates, track({ title: "Live" }))?.extra.id,
"literal-live-title",
);
});
});

describe("buildLyricSearchKeyword", () => {
Expand Down
50 changes: 44 additions & 6 deletions electron/main/apis/common/lyric/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,46 @@ const durationClose = (leftMs?: number, rightMs?: number, tolMs = 5000): boolean
* 时长差是否大到能确认"不是同一首"
* @param leftMs - 左时长(ms)
* @param rightMs - 右时长(ms)
* @param tolMs - 容差(ms)
*/
const durationFar = (leftMs?: number, rightMs?: number, tolMs = 20000): boolean => {
const durationFar = (leftMs?: number, rightMs?: number): boolean => {
if (!leftMs || !rightMs) return false;
return Math.abs(leftMs - rightMs) > tolMs;
return Math.abs(leftMs - rightMs) > Math.max(8000, rightMs * 0.04);
};

const VERSION_MARKERS: Array<{ key: string; pattern: RegExp }> = [
{ key: "live", pattern: /\blive\b|现场/i },
{ key: "remix", pattern: /\bremix\b|混音/i },
{ key: "instrumental", pattern: /\binstrumental\b|伴奏/i },
{ key: "acoustic", pattern: /\bacoustic\b|不插电/i },
{ key: "cover", pattern: /\bcover\b|翻唱/i },
{ key: "demo", pattern: /\bdemo\b/i },
{ key: "remaster", pattern: /\bremaster(?:ed)?\b|重制/i },
{ key: "edit", pattern: /\bedit\b/i },
{ key: "new", pattern: /新版/i },
{ key: "old", pattern: /旧版/i },
];

/** 提取标题中的录音版本标记 */
const versionMarkers = (text: string): string[] =>
VERSION_MARKERS.filter(({ pattern }) => pattern.test(text))
.map(({ key }) => key)
.sort();

/** 双方显式版本标记必须一致 */
const versionMatches = (left: string, right: string): boolean => {
const leftMarkers = versionMarkers(left);
const rightMarkers = versionMarkers(right);
if (leftMarkers.length === 0 && rightMarkers.length === 0) return true;
return leftMarkers.join("|") === rightMarkers.join("|");
};

/** 去掉已经单独比较的版本标记,避免中英文标记影响歌名主体 */
const stripVersionMarkers = (text: string): string => {
const stripped = VERSION_MARKERS.reduce(
(result, { pattern }) => result.replace(pattern, ""),
text,
).trim();
return stripped || text;
};

/** 子串命中时短串占长串的最低长度比,过低视为巧合 */
Expand All @@ -91,7 +126,8 @@ const NAME_CONTAIN_MIN_RATIO = 0.34;
*
* 硬性条件(不满足直接跳过)
* - name 全等,或双向 includes 且短串占长串比例 ≥ NAME_CONTAIN_MIN_RATIO
* - 双方都给了 duration 时,差距不能超过 20s
* - 双方都给了 duration 时,差距不能超过 max(8s, 曲长 × 4%)
* - 标题中的现场、混音、伴奏等版本标记必须一致
* - track 有 artist 时,候选必须命中至少一个 artist,避免同名异歌手误匹配
*
* 打分规则(分数越高越优先)
Expand All @@ -104,7 +140,7 @@ export const pickBestCandidate = <E>(
candidates: LyricCandidate<E>[],
track: Track,
): LyricCandidate<E> | null => {
const trackName = normalize(track.title);
const trackName = normalize(stripVersionMarkers(track.title));
const trackArtists = normalizeTrackArtists(track);
const trackAlbum = normalize(track.album?.name);
const trackDuration = track.duration;
Expand All @@ -113,9 +149,11 @@ export const pickBestCandidate = <E>(
let bestScore = 0;

for (const candidate of candidates) {
const candName = normalize(candidate.name);
const candName = normalize(stripVersionMarkers(candidate.name));
const candAlbum = normalize(candidate.album);

if (!versionMatches(candidate.name, track.title)) continue;

const nameExact = candName.length > 0 && candName === trackName;
if (!nameExact) {
if (!bothContains(candName, trackName)) continue;
Expand Down
2 changes: 1 addition & 1 deletion electron/main/database/lyricMatchCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const TTL_MS = 30 * 24 * 60 * 60 * 1000;
/** 时长按 5s 桶归一,避免不同来源元数据微差导致 miss */
const DURATION_BUCKET_MS = 5000;
/** 指纹规则版本,变更匹配规则时递增以避开旧误匹配缓存 */
const FINGERPRINT_VERSION = "v2";
const FINGERPRINT_VERSION = "v3";

/** 命中记录 */
export interface MatchedRecord {
Expand Down
Loading