Files
AlgerMusicPlayer/src/renderer/utils/localMusicUtils.ts
T

260 lines
7.4 KiB
TypeScript
Raw Normal View History

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';
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,
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);
// 封面统一走落盘文件 + 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,
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,
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 }
},
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);
}