diff --git a/electron/main/ipc/ipc-file.ts b/electron/main/ipc/ipc-file.ts index b32292efb..716fbac2c 100644 --- a/electron/main/ipc/ipc-file.ts +++ b/electron/main/ipc/ipc-file.ts @@ -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"; @@ -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), @@ -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, diff --git a/electron/main/services/LocalMusicService.ts b/electron/main/services/LocalMusicService.ts index ef426c26b..67e77d477 100644 --- a/electron/main/services/LocalMusicService.ts +++ b/electron/main/services/LocalMusicService.ts @@ -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; @@ -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, diff --git a/electron/main/utils/helper.ts b/electron/main/utils/helper.ts index c0b723668..589f1e8bc 100644 --- a/electron/main/utils/helper.ts +++ b/electron/main/utils/helper.ts @@ -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"); diff --git a/package.json b/package.json index d24849447..998a240d9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 831bdf26b..754223879 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: got: specifier: ^14.6.5 version: 14.6.5 + iconv-lite: + specifier: ^0.7.1 + version: 0.7.1 js-cookie: specifier: ^3.0.5 version: 3.0.5 @@ -3905,6 +3908,10 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.1: + resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -8281,7 +8288,7 @@ snapshots: content-type: 1.0.5 debug: 4.4.3 http-errors: 2.0.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.1 on-finished: 2.4.1 qs: 6.14.0 raw-body: 3.0.2 @@ -9739,6 +9746,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.1: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -10728,7 +10739,7 @@ snapshots: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.1 unpipe: 1.0.0 react@19.2.3: {} diff --git a/src/core/player/PlayModeManager.ts b/src/core/player/PlayModeManager.ts index 91bb47ab3..30118abce 100644 --- a/src/core/player/PlayModeManager.ts +++ b/src/core/player/PlayModeManager.ts @@ -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"; } diff --git a/src/core/player/PlayerController.ts b/src/core/player/PlayerController.ts index 055a5eb28..66e9acaa0 100644 --- a/src/core/player/PlayerController.ts +++ b/src/core/player/PlayerController.ts @@ -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; @@ -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") { diff --git a/src/core/player/SongManager.ts b/src/core/player/SongManager.ts index 505a2e81a..a47b73e90 100644 --- a/src/core/player/SongManager.ts +++ b/src/core/player/SongManager.ts @@ -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}`; +}; + /** * 歌曲解锁服务器 */ @@ -52,7 +81,7 @@ class SongManager { ); if (cachePath) { console.log(`🚀 [${id}] 由本地音乐缓存提供`); - return `file://${cachePath}`; + return pathToFileUrl(cachePath); } } catch (e) { console.error(`❌ [${id}] 检查缓存失败:`, e); @@ -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) }; } // 在线歌曲 diff --git a/src/utils/time.ts b/src/utils/time.ts index 59ab069b4..b4b656ff0 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -61,7 +61,14 @@ export const formatCommentTime = (timestamp: number): string => { return `${Math.floor(diff / 60)}小时前`; } else if (diff < 525600) { // 1年约等于 525600分钟 - return dayjs(timestamp).format("MM-DD HH:mm"); + const commentYear = dayjs(timestamp).year(); + const currentYear = now.year(); + // 如果是今年的评论,不显示年份;跨年则显示 + if (commentYear === currentYear) { + return dayjs(timestamp).format("MM-DD HH:mm"); + } else { + return dayjs(timestamp).format("YYYY-MM-DD HH:mm"); + } } else { return dayjs(timestamp).format("YYYY-MM-DD HH:mm"); } diff --git a/src/views/Local/layout.vue b/src/views/Local/layout.vue index ac9e460a6..87f93216f 100644 --- a/src/views/Local/layout.vue +++ b/src/views/Local/layout.vue @@ -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 }; @@ -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} 个文件`; } }; // 监听进度