Skip to content
Merged
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
23 changes: 23 additions & 0 deletions electron/main/database/LocalMusicDB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,27 @@ export class LocalMusicDB {
if (!this.db) return [];
return this.db.prepare("SELECT * FROM tracks").all() as MusicTrack[];
}

/**
* 获取指定目录下的所有歌曲
* @param dirPath 目录路径
*/
public getTracksInPath(dirPath: string): MusicTrack[] {
if (!this.db) return [];
// 确保路径以分隔符结尾,避免匹配到同名前缀的其他目录
const pathWithSep =
dirPath.endsWith("/") || dirPath.endsWith("\\") ? dirPath : dirPath + "/";
// 先统一路径分隔符
const unixBase = pathWithSep.replace(/\\/g, "/");
const winBase = pathWithSep.replace(/\//g, "\\");
// 转义 LIKE 通配符(使用 ^ 作为转义字符,同时转义 ^ 本身)
const escapeLike = (s: string) =>
s.replace(/\^/g, "^^").replace(/%/g, "^%").replace(/_/g, "^_");
const unixPath = escapeLike(unixBase) + "%";
const winPath = escapeLike(winBase) + "%";
// 使用 OR 查询并指定 ESCAPE 字符
return this.db
.prepare("SELECT * FROM tracks WHERE path LIKE ? ESCAPE '^' OR path LIKE ? ESCAPE '^'")
.all(unixPath, winPath) as MusicTrack[];
}
}
54 changes: 23 additions & 31 deletions electron/main/ipc/ipc-file.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { MusicTrack } from "../database/LocalMusicDB";
import { app, dialog, ipcMain, shell } from "electron";
import { access, mkdir, unlink, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
Expand All @@ -8,6 +7,7 @@ import { DownloadService } from "../services/DownloadService";
import { MusicMetadataService } from "../services/MusicMetadataService";
import { useStore } from "../store";
import { chunkArray } from "../utils/helper";
import { processMusicList } from "../utils/format";

/** 本地音乐服务 */
const localMusicService = new LocalMusicService();
Expand All @@ -16,6 +16,13 @@ const downloadService = new DownloadService();
/** 音乐元数据服务 */
const musicMetadataService = new MusicMetadataService();

/** 获取封面目录路径 */
const getCoverDir = (): string => {
const store = useStore();
const localCachePath = join(store.get("cachePath"), "local-data");
return join(localCachePath, "covers");
};

/**
* 处理本地音乐同步(批量流式传输)
* @param event IPC 调用事件
Expand All @@ -26,25 +33,7 @@ const handleLocalMusicSync = async (
dirs: string[],
): Promise<{ success: boolean; message?: string }> => {
try {
// 获取封面目录路径
const store = useStore();
const localCachePath = join(store.get("cachePath"), "local-data");
const coverDir = join(localCachePath, "covers");
/**
* 处理音乐封面路径
* @param tracks 音乐列表
* @returns 处理后的音乐列表
*/
const processTracksCover = (tracks: MusicTrack[]) => {
return tracks.map((track) => {
let coverPath: string | undefined;
if (track.cover) {
const fullPath = join(coverDir, track.cover);
coverPath = `file://${fullPath.replace(/\\/g, "/")}`;
}
return { ...track, cover: coverPath };
});
};
const coverDir = getCoverDir();
// 刷新本地音乐库
const allTracks = await localMusicService.refreshLibrary(
dirs,
Expand All @@ -54,7 +43,7 @@ const handleLocalMusicSync = async (
() => {},
);
// 处理音乐封面路径
const finalTracks = processTracksCover(allTracks);
const finalTracks = processMusicList(allTracks, coverDir);
// 分块发送
const CHUNK_SIZE = 1000;
for (const chunk of chunkArray(finalTracks, CHUNK_SIZE)) {
Expand Down Expand Up @@ -120,9 +109,17 @@ const initFileIpc = (): void => {
// 本地音乐同步(批量流式传输)
ipcMain.handle("local-music-sync", handleLocalMusicSync);

// 遍历音乐文件
ipcMain.handle("get-music-files", async (_, dirPath: string) => {
return musicMetadataService.scanDirectory(dirPath);
// 获取已下载音乐
ipcMain.handle("get-downloaded-songs", async (_event, dirPath: string) => {
try {
const coverDir = getCoverDir();
// 扫描指定目录
const tracks = await localMusicService.scanDirectory(dirPath);
return processMusicList(tracks, coverDir);
} catch (err) {
console.error("Failed to get downloaded songs:", err);
return [];
}
});

// 获取音乐元信息
Expand All @@ -131,7 +128,7 @@ const initFileIpc = (): void => {
});

// 修改音乐元信息
ipcMain.handle("set-music-metadata", async (_, path: string, metadata: any) => {
ipcMain.handle("set-music-metadata", async (_, path: string, metadata) => {
return musicMetadataService.setMetadata(path, metadata);
});
Comment thread
imsyy marked this conversation as resolved.
Comment on lines +131 to 133

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里 metadata 参数没有类型注解,会在开启 noImplicitAny/strict 时变成隐式 any,导致 node 端 typecheck 失败。建议把参数类型显式标注为 MusicMetadataInput(可从 MusicMetadataService 导出/引入),或至少标注为 unknown 并在 setMetadata 内做校验/转换。

Suggested change
ipcMain.handle("set-music-metadata", async (_, path: string, metadata) => {
return musicMetadataService.setMetadata(path, metadata);
});
ipcMain.handle(
"set-music-metadata",
async (_, path: string, metadata: Parameters<MusicMetadataService["setMetadata"]>[1]) => {
return musicMetadataService.setMetadata(path, metadata);
},
);

Copilot uses AI. Check for mistakes.

Expand Down Expand Up @@ -176,16 +173,11 @@ const initFileIpc = (): void => {
// 规范化路径
const resolvedPath = resolve(path);
// 检查文件夹是否存在
try {
await access(resolvedPath);
} catch {
throw new Error("❌ Folder not found");
}
await access(resolvedPath);
// 打开文件夹
shell.showItemInFolder(resolvedPath);
} catch (error) {
ipcLog.error("❌ Folder open error", error);
throw error;
}
});

Expand Down
70 changes: 40 additions & 30 deletions electron/main/services/LocalMusicService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export class LocalMusicService {
/** 初始化 */
private async ensureInitialized(): Promise<void> {
const { dbPath, jsonPath, coverDir } = this.paths;

// 如果路径变了,强制重新初始化
if (this.lastDbPath && this.lastDbPath !== dbPath) {
this.initPromise = null;
Expand All @@ -45,87 +44,77 @@ export class LocalMusicService {
}
}
this.lastDbPath = dbPath;

if (this.initPromise) {
return this.initPromise;
}

if (this.initPromise) return this.initPromise;
this.initPromise = (async () => {
if (!existsSync(coverDir)) {
await mkdir(coverDir, { recursive: true });
}

if (!this.db) {
this.db = new LocalMusicDB(dbPath);
this.db.init();
}

await this.db.migrateFromJsonIfNeeded(jsonPath);
})();

return this.initPromise;
}

/**
* 刷新所有库文件夹
* 内部扫描方法
* @param dirPaths 文件夹路径数组
* @param ignoreDelete 是否忽略删除操作(默认为 false)
* @param onProgress 进度回调
* @param onTracksBatch 批量track回调(用于流式传输,每批发送多个tracks)
* @param onTracksBatch 批量track回调
*/
async refreshLibrary(
private async _scan(
dirPaths: string[],
ignoreDelete: boolean = false,
onProgress?: (current: number, total: number) => void,
onTracksBatch?: (tracks: MusicTrack[]) => void,
) {
const { dbPath, coverDir } = this.paths;

// 运行锁:如果正在刷新,抛出特定错误
// 运行锁
if (this.isRefreshing) {
throw new Error("SCAN_IN_PROGRESS");
}

// 确保初始化完成
await this.ensureInitialized();
if (!this.db) throw new Error("DB not initialized");

if (!dirPaths || dirPaths.length === 0) {
// 如果没有目录,清空数据库
this.db.clearTracks();
return [];
if (!ignoreDelete) {
this.db.clearTracks();
}
return;
}

this.isRefreshing = true;

try {
console.time("RustScanStream");

await new Promise<void>((resolve, reject) => {
tools
.scanMusicLibrary(dbPath, dirPaths, coverDir, (err, event) => {
if (err) {
processLog.error("[LocalMusicService] 原生模块扫描时出错:", err);
return;
}

if (!event) return;

// 处理事件
try {
switch (event.event) {
// 进度更新
case "progress":
if (event.progress) {
onProgress?.(event.progress.current, event.progress.total);
}
break;

// 批量数据
case "batch":
if (event.tracks && event.tracks.length > 0) {
this.db?.addTracks(event.tracks);
onTracksBatch?.(event.tracks);
}
break;

// 扫描结束
case "end":
if (event.deletedPaths && event.deletedPaths.length > 0) {
if (!ignoreDelete && event.deletedPaths && event.deletedPaths.length > 0) {
this.db?.deleteTracks(event.deletedPaths);
}
resolve();
Comment on lines 116 to 120

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scanDirectory() 通过 ignoreDelete=true 完全跳过了 deletedPaths 的处理,这会导致扫描目录内被删除/移动的文件仍然残留在 DB 中,getTracksInPath() 也会继续返回这些陈旧记录(例如下载目录里手动删歌后列表仍显示)。建议在 ignoreDelete 场景下仅过滤并删除属于本次扫描目录前缀的 deletedPaths(避免误删其它库目录),而不是全部忽略。

Copilot uses AI. Check for mistakes.
Expand All @@ -139,10 +128,7 @@ export class LocalMusicService {
reject(err);
});
});

console.timeEnd("RustScanStream");

return this.db.getAllTracks();
} catch (err) {
processLog.error("[LocalMusicService]: 扫描失败", err);
throw err;
Expand All @@ -151,6 +137,30 @@ export class LocalMusicService {
}
}

/**
* 刷新所有库文件夹
* @param dirPaths 文件夹路径数组
* @param onProgress 进度回调
* @param onTracksBatch 批量track回调
*/
async refreshLibrary(
dirPaths: string[],
onProgress?: (current: number, total: number) => void,
onTracksBatch?: (tracks: MusicTrack[]) => void,
) {
await this._scan(dirPaths, false, onProgress, onTracksBatch);
return this.db?.getAllTracks() || [];
}

/**
* 扫描指定目录
* @param dirPath 目录路径
*/
async scanDirectory(dirPath: string): Promise<MusicTrack[]> {
await this._scan([dirPath], true);
return this.db?.getTracksInPath(dirPath) || [];
}

/** 获取所有音乐 */
async getAllTracks(): Promise<MusicTrack[]> {
await this.ensureInitialized();
Expand Down
17 changes: 16 additions & 1 deletion electron/main/services/MusicMetadataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ import pLimit from "p-limit";
type toolModule = typeof import("@native/tools");
const tools: toolModule = loadNativeModule("tools.node", "tools");

/** 修改音乐元数据的输入参数 */
export interface MusicMetadataInput {
name?: string;
artist?: string;
album?: string;
alia?: string;
lyric?: string;
cover?: string | null;
albumArtist?: string;
genre?: string;
year?: number;
trackNumber?: number;
discNumber?: number;
}

/** 支持的音乐文件扩展名列表 */
const MUSIC_EXTENSIONS = ["mp3", "wav", "flac", "aac", "webm", "m4a", "ogg", "aiff", "aif", "aifc"];

Expand Down Expand Up @@ -279,7 +294,7 @@ export class MusicMetadataService {
* @param path 文件路径
* @param metadata 元数据对象
*/
async setMetadata(path: string, metadata: any) {
async setMetadata(path: string, metadata: MusicMetadataInput) {
try {
const {
name,
Expand Down
26 changes: 26 additions & 0 deletions electron/main/utils/format.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { type MusicTrack } from "../database/LocalMusicDB";
import { join } from "path";

/**
* 获取艺术家名称
* @param artists 艺术家数组
Expand All @@ -14,3 +17,26 @@ export const getArtistNames = (artists: any): string[] => {
}
return [];
};

/**
* 处理音乐列表
* @param tracks 音乐列表
* @param coverDir 封面目录
* @returns 处理后的音乐列表
*/
export const processMusicList = (tracks: MusicTrack[], coverDir: string) => {
return tracks.map((track) => {
let cover: string | undefined;
if (track.cover) {
const fullPath = join(coverDir, track.cover);
cover = `file://${fullPath.replace(/\\/g, "/")}`;
}
return {
...track,
name: track.title,
cover,
// 码率映射到 quality 字段
quality: track.bitrate ?? 0,
};
Comment thread
imsyy marked this conversation as resolved.
});
};
2 changes: 1 addition & 1 deletion src/api/streaming/subsonic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export const convertSubsonicSong = (
}
: undefined,
duration: (song.duration || 0) * 1000, // 转换为毫秒
size: song.size ? Number((song.size / (1024 * 1024)).toFixed(2)) : 0,
size: song.size || 0,
quality: inferQuality(song),
free: 0,
mv: null,
Expand Down
4 changes: 2 additions & 2 deletions src/components/Card/SongCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@
</n-text>
<!-- 大小 -->
<n-text v-if="song.size && !hiddenSize && !isSmallScreen" class="meta size" depth="3">
{{ song.size }}M
{{ formatFileSize(song.size) }}
</n-text>
</div>
</div>
Expand All @@ -201,7 +201,7 @@
<script setup lang="ts">
import { QualityType, type SongType } from "@/types/main";
import { useStatusStore, useMusicStore, useDataStore, useSettingStore } from "@/stores";
import { formatNumber } from "@/utils/helper";
import { formatNumber, formatFileSize } from "@/utils/helper";
import { openJumpArtist } from "@/utils/modal";
import { removeBrackets } from "@/utils/format";
import { toLikeSong } from "@/utils/auth";
Expand Down
2 changes: 2 additions & 0 deletions src/components/Player/FullPlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
}"
:class="['full-player', { 'show-comment': isShowComment }]"
@mouseleave="playerLeave"
@mousemove="playerMove"
@click="playerMove"
>
<!-- 背景 -->
<PlayerBackground />
Expand Down
Loading
Loading