Skip to content
Draft
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
41 changes: 32 additions & 9 deletions electron/main/ipc/ipc-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "path";
import { access, mkdir, readdir, readFile, stat, unlink, writeFile } from "fs/promises";
import { parseFile } from "music-metadata";
import { getFileID, getFileMD5, metaDataLyricsArrayToLrc } from "../utils/helper";
import { getFileID, getFileMD5, metaDataLyricsArrayToLrc, tryDecodeShiftJIS } from "../utils/helper";
import { File, Picture, Id3v2Settings, TagTypes } from "node-taglib-sharp";
import { ipcLog } from "../logger";
import { createWriteStream } from "fs";
Expand Down Expand Up @@ -148,9 +148,9 @@ const initFileIpc = (): void => {

return {
id: getFileID(fullPath),
name: common.title || basename(fullPath, ext),
artists: common.artists?.[0] || common.artist,
album: common.album || "",
name: tryDecodeShiftJIS(common.title || "") || basename(fullPath, ext),
artists: tryDecodeShiftJIS(common.artists?.[0] || common.artist || ""),
album: tryDecodeShiftJIS(common.album || ""),
alia: common.comment?.[0]?.text || "",
duration: (format?.duration ?? 0) * 1000,
size: (size / (1024 * 1024)).toFixed(2),
Expand All @@ -177,17 +177,40 @@ const initFileIpc = (): void => {
try {
const filePath = resolve(path).replace(/\\/g, "/");
const { common, format } = await parseFile(filePath);
// 对可能存在乱码的文本字段进行 Shift_JIS 编码修复
const decodedCommon = {
...common,
title: tryDecodeShiftJIS(common.title || ""),
artist: tryDecodeShiftJIS(common.artist || ""),
album: tryDecodeShiftJIS(common.album || ""),
artists: common.artists?.map((a) => tryDecodeShiftJIS(a)),
albumartist: tryDecodeShiftJIS(common.albumartist || ""),
// 解码歌词
lyrics: common.lyrics?.map((lyric) => ({
...lyric,
text: lyric.text ? tryDecodeShiftJIS(lyric.text) : undefined,
syncText: lyric.syncText?.map((sync) => ({
...sync,
text: tryDecodeShiftJIS(sync.text || ""),
})),
})),
// 解码评论(IComment 类型包含 text 属性)
comment: common.comment?.map((c) => ({
...c,
text: typeof c === "string" ? tryDecodeShiftJIS(c) : tryDecodeShiftJIS(c.text || ""),
})),
};
return {
// 文件名称
fileName: basename(filePath),
// 文件大小
fileSize: (await stat(filePath)).size / (1024 * 1024),
// 元信息
common,
// 歌词
// 元信息(已修复编码)
common: decodedCommon,
// 歌词(使用已解码的 decodedCommon)
lyric:
metaDataLyricsArrayToLrc(common?.lyrics?.[0]?.syncText || []) ||
common?.lyrics?.[0]?.text ||
metaDataLyricsArrayToLrc(decodedCommon?.lyrics?.[0]?.syncText || []) ||
decodedCommon?.lyrics?.[0]?.text ||
"",
// 音质信息
format,
Expand Down
10 changes: 6 additions & 4 deletions electron/main/services/LocalMusicService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { existsSync } from "fs";
import { createHash } from "crypto";
import { useStore } from "../store";
import { type IAudioMetadata, parseFile } from "music-metadata";
import { tryDecodeShiftJIS } from "../utils/helper";
import FastGlob, { type Entry } from "fast-glob";
import pLimit from "p-limit";


/** 当前本地音乐库 DB 版本,用于控制缓存结构升级 */
const CURRENT_DB_VERSION = 2;

Expand Down Expand Up @@ -292,13 +294,13 @@ export class LocalMusicService {
if (metadata.format.duration && metadata.format.duration > 7200) return;
// 提取封面
const coverPath = await this.extractCover(metadata, id);
// 构建音乐数据
// 构建音乐数据(对文本字段应用 Shift_JIS 编码修复)
const track: MusicTrack = {
id,
path: filePath,
title: metadata.common.title || basename(filePath),
artist: metadata.common.artist || "Unknown Artist",
album: metadata.common.album || "Unknown Album",
title: tryDecodeShiftJIS(metadata.common.title || "") || basename(filePath),
artist: tryDecodeShiftJIS(metadata.common.artist || "") || "Unknown Artist",
album: tryDecodeShiftJIS(metadata.common.album || "") || "Unknown Album",
duration: (metadata.format.duration || 0) * 1000,
mtime,
size,
Expand Down
103 changes: 103 additions & 0 deletions electron/main/utils/helper.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,114 @@
import { createHash } from "crypto";
import { readFile } from "fs/promises";
import iconv from "iconv-lite";

/**
* 检测字符串是否包含高位 Latin-1 字符(0x80-0xFF)
* 这是 Shift_JIS 被错误解析为 ISO-8859-1 后的特征
*/
const hasHighLatin1Chars = (text: string): boolean => {
if (!text) return false;
let highAsciiCount = 0;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code >= 0x80 && code <= 0xff) {
highAsciiCount++;
}
}
return highAsciiCount > 0 && highAsciiCount / text.length > 0.2;
};

/**
* 检测字符串是否包含日文乱码特征字符
* 当 Shift_JIS 内容被错误解析时,会产生一些特定的罕见 CJK 字符
* 例如:僆、僕、僫、儖、僒、僂、儞、僪、儔、儅 等
*/
const hasJapaneseMojibakeChars = (text: string): boolean => {
if (!text) return false;
// 这些字符范围在正常日文/中文文本中非常罕见
// 但在 Shift_JIS 被错误解析时经常出现
// 范围:U+50xx (僂僆僊僔僕僗僚僛僜僞僟僠僡僢僣僤僥僦僧僨僩僪僫僬僭僮僯僰僱僲僳僴僵僶僷僸價僺僻僼僽僾僿)
// 范围:U+51xx (儀儁儂儃億儅儆儇儈儉儊儋儌儍儎儏儐儑儒儓儔儕儖儗儘儙儚儛儜儝儞償儠儡儢儣儤儥儦儧儨儩優儫儬儭儮儯)
let suspiciousCount = 0;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
// 僂-僿 (U+5002 - U+503F) 和 儀-儯 (U+5100 - U+516F) 这些字符很少在正常文本中使用
if ((code >= 0x5002 && code <= 0x503f) || (code >= 0x5100 && code <= 0x516f)) {
suspiciousCount++;
}
// 乕乗乚乛乢乣乤乥乧乨乩乪乫乬乭乮乯 等也是乱码特征
if (code >= 0x4e55 && code <= 0x4e6f) {
suspiciousCount++;
}
}
// 如果有超过 20% 的字符是这类可疑字符,很可能是乱码
return suspiciousCount > 0 && suspiciousCount / text.length > 0.15;
};

/**
* 检测解码后的文本是否合理(包含常见日文字符)
*/
const isValidDecodedJapanese = (text: string): boolean => {
if (!text) return false;
let validCount = 0;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
// 平假名 (U+3040 - U+309F)
if (code >= 0x3040 && code <= 0x309f) validCount++;
// 片假名 (U+30A0 - U+30FF)
else if (code >= 0x30a0 && code <= 0x30ff) validCount++;
// 常用汉字 (U+4E00 - U+9FFF) - 但需要是常见汉字
else if (code >= 0x4e00 && code <= 0x9fff) validCount++;
// ASCII (0x20 - 0x7E)
else if (code >= 0x20 && code <= 0x7e) validCount++;
// 全角字符
else if (code >= 0xff00 && code <= 0xffef) validCount++;
}
return validCount / text.length > 0.8;
};

/**
* 尝试将可能被错误解析的 Shift_JIS 文本修复为正确的 UTF-8
* @param text 可能包含乱码的文本
* @returns 修复后的文本
*/
export const tryDecodeShiftJIS = (text: string): string => {
if (!text) return text;

// 检测是否需要解码
const needsDecode = hasHighLatin1Chars(text) || hasJapaneseMojibakeChars(text);
if (!needsDecode) {
return text;
}

// 解码策略列表
const strategies: { from: BufferEncoding; to: string }[] = [
{ from: "latin1", to: "Shift_JIS" },
{ from: "utf8", to: "Shift_JIS" }, // 某些工具会错误地将 Shift_JIS 当作 UTF-8 解码
{ from: "latin1", to: "CP932" }, // Windows 日文扩展
];

for (const { from, to } of strategies) {
try {
const buffer = Buffer.from(text, from);
const decoded = iconv.decode(buffer, to);
if (isValidDecodedJapanese(decoded) && !hasJapaneseMojibakeChars(decoded)) {
return decoded;
}
} catch {
// 解码失败,尝试下一种方案
}
}

return text;
};

/**
* 生成文件唯一ID
* @param filePath 文件路径
* @returns 唯一ID
*/

export const getFileID = (filePath: string): number => {
// SHA-256
const hash = createHash("sha256");
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"get-port": "^7.1.0",
"github-markdown-css": "^5.8.1",
"got": "^14.6.5",
"iconv-lite": "^0.7.1",
"js-cookie": "^3.0.5",
"jss": "^10.10.0",
"jss-preset-default": "^10.10.0",
Expand Down
15 changes: 13 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions src/core/player/PlayModeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,17 @@ export class PlayModeManager {

/**
* 计算下一个随机模式
* @param skipHeartbeat 是否跳过心动模式(本地文件播放时应跳过)
*/
public calculateNextShuffleMode(currentMode: ShuffleModeType): ShuffleModeType {
public calculateNextShuffleMode(
currentMode: ShuffleModeType,
skipHeartbeat: boolean = false,
): ShuffleModeType {
if (currentMode === "off") return "on";
if (currentMode === "on") return "heartbeat";
if (currentMode === "on") {
// 如果需要跳过心动模式,直接回到关闭状态
return skipHeartbeat ? "off" : "heartbeat";
}
return "off";
}

Expand Down
11 changes: 10 additions & 1 deletion src/core/player/PlayerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ class PlayerController {
// 停止当前播放
audioManager.stop();
musicStore.playSong = playSongData;

// 如果播放本地歌曲且当前为心动模式,自动关闭心动模式
if (playSongData.path && statusStore.shuffleMode === "heartbeat") {
await this.playModeManager.toggleShuffle("off");
}
// 重置播放进度
statusStore.currentTime = 0;
statusStore.progress = 0;
Expand Down Expand Up @@ -954,10 +959,14 @@ class PlayerController {
*/
public async toggleShuffle(mode?: ShuffleModeType) {
const statusStore = useStatusStore();
const musicStore = useMusicStore();
const currentMode = statusStore.shuffleMode;

// 检测当前播放的歌曲是否为本地文件,本地文件不支持心动模式
const isCurrentSongLocal = !!musicStore.playSong?.path;

// 预判下一个模式
const nextMode = mode ?? this.playModeManager.calculateNextShuffleMode(currentMode);
const nextMode = mode ?? this.playModeManager.calculateNextShuffleMode(currentMode, isCurrentSongLocal);

// 已经是心动模式,再次触发心动模式并播放
if (currentMode === "heartbeat" && nextMode === "heartbeat") {
Expand Down
33 changes: 31 additions & 2 deletions src/core/player/SongManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,35 @@ import { formatSongsList } from "@/utils/format";
import { handleSongQuality } from "@/utils/helper";
import { openUserLogin } from "@/utils/modal";

/**
* 将文件路径转换为 file:// URL
* 正确处理 Windows 和 Unix 路径中的空格、中文和特殊字符
*/
const pathToFileUrl = (filePath: string): string => {
// 统一将反斜杠转换为正斜杠
let normalizedPath = filePath.replace(/\\/g, '/');

// 处理 Windows 盘符(如 C:/)
// file:// URL 在 Windows 上需要格式为 file:///C:/...
if (/^[a-zA-Z]:/.test(normalizedPath)) {
normalizedPath = '/' + normalizedPath;
}

// 对路径中的特殊字符进行编码,但保留斜杠和冒号
const encodedPath = normalizedPath
.split('/')
.map((segment, index) => {
// 第一个段可能是空句(因为前缀 /),或者第二个段是盘符(如 C:)
if (segment === '' || (index === 1 && /^[a-zA-Z]:$/.test(segment))) {
return segment;
}
return encodeURIComponent(segment);
})
.join('/');

return `file://${encodedPath}`;
};

/**
* 歌曲解锁服务器
*/
Expand Down Expand Up @@ -52,7 +81,7 @@ class SongManager {
);
if (cachePath) {
console.log(`🚀 [${id}] 由本地音乐缓存提供`);
return `file://${cachePath}`;
return pathToFileUrl(cachePath);
}
} catch (e) {
console.error(`❌ [${id}] 检查缓存失败:`, e);
Expand Down Expand Up @@ -254,7 +283,7 @@ class SongManager {
console.error("❌ 本地文件不存在");
return { id: song.id, url: undefined };
}
return { id: song.id, url: `file://${song.path}` };
return { id: song.id, url: pathToFileUrl(song.path) };
}

// 在线歌曲
Expand Down
4 changes: 2 additions & 2 deletions src/views/Local/layout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ const getAllLocalMusic = debounce(

// 加载提示
if (showTip) {
loadingMsg.value = window.$message.loading("正在获取本地歌曲", {
loadingMsg.value = window.$message.loading("正在扫描本地歌曲...", {
duration: 0,
});
syncProgress.value = { current: 0, total: 0 };
Expand Down Expand Up @@ -496,7 +496,7 @@ onMounted(() => {
if (!total || total <= 0) return;
syncProgress.value = { current, total };
if (loadingMsg.value) {
loadingMsg.value.content = `正在获取本地歌曲(${current}/${total}`;
loadingMsg.value.content = `已扫描 ${current} / ${total} 个文件`;
}
};
// 监听进度
Expand Down