2026-02-06 17:49:14 +08:00
|
|
|
// 本地音乐工具函数
|
|
|
|
|
// 提供格式过滤、元数据 fallback、类型转换、搜索过滤、增量扫描等功能
|
|
|
|
|
|
|
|
|
|
import type { LocalMusicEntry, LocalMusicMeta } from '@/types/localMusic';
|
|
|
|
|
import { SUPPORTED_AUDIO_FORMATS } from '@/types/localMusic';
|
|
|
|
|
import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music';
|
|
|
|
|
import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
|
|
|
|
|
|
2026-05-17 21:36:49 +08:00
|
|
|
import { filePathToLocalUrl } from '../../shared/localUrl';
|
|
|
|
|
|
|
|
|
|
export { filePathToLocalUrl };
|
|
|
|
|
|
2026-02-06 17:49:14 +08:00
|
|
|
/**
|
|
|
|
|
* 判断文件路径是否为支持的音频格式
|
|
|
|
|
* 通过提取文件扩展名(不区分大小写)与支持格式列表比对
|
|
|
|
|
* @param filePath 文件路径
|
|
|
|
|
* @returns 是否为支持的音频格式
|
|
|
|
|
*/
|
|
|
|
|
export function isSupportedAudioFormat(filePath: string): boolean {
|
|
|
|
|
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
|
|
|
|
|
return (SUPPORTED_AUDIO_FORMATS as readonly string[]).includes(ext);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 从文件路径中提取歌曲标题(去除目录和扩展名)
|
|
|
|
|
* @param filePath 文件路径
|
|
|
|
|
* @returns 歌曲标题
|
|
|
|
|
*/
|
|
|
|
|
export function extractTitleFromFilename(filePath: string): string {
|
|
|
|
|
// 兼容 Windows 和 Unix 路径分隔符
|
|
|
|
|
const separator = filePath.includes('\\') ? '\\' : '/';
|
|
|
|
|
const filename = filePath.split(separator).pop() || filePath;
|
|
|
|
|
// 去除扩展名
|
|
|
|
|
const dotIndex = filename.lastIndexOf('.');
|
|
|
|
|
if (dotIndex > 0) {
|
|
|
|
|
return filename.slice(0, dotIndex);
|
|
|
|
|
}
|
|
|
|
|
return filename;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 构建缺失元数据时的 fallback 元数据对象
|
|
|
|
|
* 使用文件名作为标题,"未知艺术家"和"未知专辑"作为默认值
|
|
|
|
|
* @param filePath 文件路径
|
|
|
|
|
* @returns 默认的 LocalMusicMeta 对象
|
|
|
|
|
*/
|
|
|
|
|
export function buildFallbackMeta(filePath: string): LocalMusicMeta {
|
|
|
|
|
return {
|
|
|
|
|
filePath,
|
|
|
|
|
title: extractTitleFromFilename(filePath),
|
|
|
|
|
artist: '未知艺术家',
|
|
|
|
|
album: '未知专辑',
|
|
|
|
|
duration: 0,
|
2026-05-17 21:36:49 +08:00
|
|
|
coverPath: null,
|
2026-02-06 17:49:14 +08:00
|
|
|
lyrics: null,
|
|
|
|
|
fileSize: 0,
|
|
|
|
|
modifiedTime: 0
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 将 LRC 格式歌词字符串解析为 ILyric 对象
|
|
|
|
|
* 复用 yrcParser 解析能力,兼容标准 LRC 和 YRC 格式
|
|
|
|
|
* @param lrcString LRC 格式歌词文本
|
|
|
|
|
* @returns ILyric 对象,解析失败返回 null
|
|
|
|
|
*/
|
|
|
|
|
export function parseLrcToILyric(lrcString: string | null): ILyric | null {
|
|
|
|
|
if (!lrcString || typeof lrcString !== 'string') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const parseResult = parseYrcLyrics(lrcString);
|
|
|
|
|
if (!parseResult.success) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { lyrics: parsedLyrics } = parseResult.data;
|
|
|
|
|
const lrcArray: ILyricText[] = [];
|
|
|
|
|
const lrcTimeArray: number[] = [];
|
|
|
|
|
let hasWordByWord = false;
|
|
|
|
|
|
|
|
|
|
for (const line of parsedLyrics) {
|
|
|
|
|
const hasWords = line.words && line.words.length > 0;
|
|
|
|
|
if (hasWords) hasWordByWord = true;
|
|
|
|
|
|
|
|
|
|
lrcArray.push({
|
|
|
|
|
text: line.fullText,
|
|
|
|
|
trText: '',
|
|
|
|
|
words: hasWords ? (line.words as IWordData[]) : undefined,
|
|
|
|
|
hasWordByWord: hasWords,
|
|
|
|
|
startTime: line.startTime,
|
|
|
|
|
duration: line.duration
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
lrcTimeArray.push(line.startTime / 1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (lrcArray.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { lrcTimeArray, lrcArray, hasWordByWord };
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 将 LocalMusicEntry 转换为 SongResult,以复用现有播放系统
|
|
|
|
|
* @param entry 本地音乐条目
|
|
|
|
|
* @returns 兼容播放系统的 SongResult 对象
|
|
|
|
|
*/
|
|
|
|
|
export function toSongResult(entry: LocalMusicEntry): SongResult {
|
|
|
|
|
// 解析内嵌歌词为 ILyric 对象
|
|
|
|
|
const lyric = parseLrcToILyric(entry.lyrics);
|
|
|
|
|
|
2026-05-17 21:36:49 +08:00
|
|
|
// 封面统一走落盘文件 + local:// 协议;缺 coverPath 时给空串,
|
|
|
|
|
// SongItem 模板用 v-if="item.picUrl" 自动跳过渲染。
|
|
|
|
|
// 用户重新扫描会让主进程落盘新封面(参见 scanFolders 的自愈条件)
|
|
|
|
|
const coverUrl = entry.coverPath ? filePathToLocalUrl(entry.coverPath) : '';
|
|
|
|
|
|
2026-02-06 17:49:14 +08:00
|
|
|
return {
|
|
|
|
|
id: entry.id,
|
|
|
|
|
name: entry.title,
|
2026-05-17 21:36:49 +08:00
|
|
|
picUrl: coverUrl,
|
2026-02-06 17:49:14 +08:00
|
|
|
ar: [
|
|
|
|
|
{
|
|
|
|
|
name: entry.artist,
|
|
|
|
|
id: 0,
|
|
|
|
|
picId: 0,
|
|
|
|
|
img1v1Id: 0,
|
|
|
|
|
briefDesc: '',
|
|
|
|
|
picUrl: '',
|
|
|
|
|
img1v1Url: '',
|
|
|
|
|
albumSize: 0,
|
|
|
|
|
alias: [],
|
|
|
|
|
trans: '',
|
|
|
|
|
musicSize: 0,
|
|
|
|
|
topicPerson: 0
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
al: {
|
|
|
|
|
name: entry.album,
|
|
|
|
|
id: 0,
|
|
|
|
|
type: '',
|
|
|
|
|
size: 0,
|
|
|
|
|
picId: 0,
|
|
|
|
|
blurPicUrl: '',
|
|
|
|
|
companyId: 0,
|
|
|
|
|
pic: 0,
|
2026-05-17 21:36:49 +08:00
|
|
|
picUrl: coverUrl,
|
2026-02-06 17:49:14 +08:00
|
|
|
publishTime: 0,
|
|
|
|
|
description: '',
|
|
|
|
|
tags: '',
|
|
|
|
|
company: '',
|
|
|
|
|
briefDesc: '',
|
|
|
|
|
artist: {
|
|
|
|
|
name: entry.artist,
|
|
|
|
|
id: 0,
|
|
|
|
|
picId: 0,
|
|
|
|
|
img1v1Id: 0,
|
|
|
|
|
briefDesc: '',
|
|
|
|
|
picUrl: '',
|
|
|
|
|
img1v1Url: '',
|
|
|
|
|
albumSize: 0,
|
|
|
|
|
alias: [],
|
|
|
|
|
trans: '',
|
|
|
|
|
musicSize: 0,
|
|
|
|
|
topicPerson: 0
|
|
|
|
|
},
|
|
|
|
|
songs: [],
|
|
|
|
|
alias: [],
|
|
|
|
|
status: 0,
|
|
|
|
|
copyrightId: 0,
|
|
|
|
|
commentThreadId: '',
|
|
|
|
|
artists: [],
|
|
|
|
|
subType: '',
|
|
|
|
|
transName: null,
|
|
|
|
|
onSale: false,
|
|
|
|
|
mark: 0,
|
|
|
|
|
picId_str: ''
|
|
|
|
|
},
|
|
|
|
|
song: {
|
|
|
|
|
artists: [{ name: entry.artist }],
|
|
|
|
|
album: { name: entry.album }
|
|
|
|
|
},
|
2026-05-17 21:36:49 +08:00
|
|
|
playMusicUrl: filePathToLocalUrl(entry.filePath),
|
2026-02-06 17:49:14 +08:00
|
|
|
duration: entry.duration,
|
|
|
|
|
dt: entry.duration,
|
|
|
|
|
source: 'netease' as const,
|
|
|
|
|
count: 0,
|
|
|
|
|
// 内嵌歌词(如果有)
|
|
|
|
|
lyric: lyric ?? undefined,
|
|
|
|
|
// 本地音乐 URL 不会过期,设置一个极大的过期时间
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
expiredAt: Date.now() + 365 * 24 * 60 * 60 * 1000
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 按关键词搜索过滤本地音乐列表
|
|
|
|
|
* 不区分大小写,匹配歌曲标题或艺术家名称
|
|
|
|
|
* 空关键词返回完整列表
|
|
|
|
|
* @param list 本地音乐列表
|
|
|
|
|
* @param keyword 搜索关键词
|
|
|
|
|
* @returns 过滤后的音乐列表
|
|
|
|
|
*/
|
|
|
|
|
export function filterByKeyword(list: LocalMusicEntry[], keyword: string): LocalMusicEntry[] {
|
|
|
|
|
if (!keyword || keyword.trim() === '') {
|
|
|
|
|
return list;
|
|
|
|
|
}
|
|
|
|
|
const lowerKeyword = keyword.toLowerCase();
|
|
|
|
|
return list.filter((entry) => {
|
|
|
|
|
return (
|
|
|
|
|
entry.title.toLowerCase().includes(lowerKeyword) ||
|
|
|
|
|
entry.artist.toLowerCase().includes(lowerKeyword)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 增量扫描对比:找出新增或修改时间变更的文件
|
|
|
|
|
* 对比扫描到的文件列表与缓存条目,返回需要重新解析的文件路径
|
|
|
|
|
* @param files 扫描到的文件列表(包含路径和修改时间)
|
|
|
|
|
* @param cached 已缓存的本地音乐条目
|
|
|
|
|
* @returns 需要重新解析的文件路径列表
|
|
|
|
|
*/
|
|
|
|
|
export function getChangedFiles(
|
|
|
|
|
files: { path: string; modifiedTime: number }[],
|
|
|
|
|
cached: LocalMusicEntry[]
|
|
|
|
|
): string[] {
|
|
|
|
|
// 构建缓存映射:filePath -> modifiedTime
|
|
|
|
|
const cachedMap = new Map<string, number>();
|
|
|
|
|
for (const entry of cached) {
|
|
|
|
|
cachedMap.set(entry.filePath, entry.modifiedTime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return files
|
|
|
|
|
.filter((file) => {
|
|
|
|
|
const cachedTime = cachedMap.get(file.path);
|
|
|
|
|
// 缓存中不存在(新文件)或修改时间不匹配(已变更)
|
|
|
|
|
return cachedTime === undefined || cachedTime !== file.modifiedTime;
|
|
|
|
|
})
|
|
|
|
|
.map((file) => file.path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 缓存清理:移除文件已不存在的条目
|
|
|
|
|
* @param entries 缓存的本地音乐条目列表
|
|
|
|
|
* @param existsMap 文件存在性映射(filePath -> 是否存在)
|
|
|
|
|
* @returns 清理后的条目列表(仅保留文件仍存在的条目)
|
|
|
|
|
*/
|
|
|
|
|
export function removeStaleEntries(
|
|
|
|
|
entries: LocalMusicEntry[],
|
|
|
|
|
existsMap: Record<string, boolean>
|
|
|
|
|
): LocalMusicEntry[] {
|
|
|
|
|
return entries.filter((entry) => existsMap[entry.filePath] === true);
|
|
|
|
|
}
|