Skip to content

Commit f55fbb8

Browse files
authored
Merge pull request #606 from imsyy/dev-dl
✨ feat: 优化批量下载
2 parents e6e62b0 + 5e777d6 commit f55fbb8

6 files changed

Lines changed: 254 additions & 33 deletions

File tree

electron/main/ipc/ipc-file.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from
33
import { access, readdir, readFile, stat, unlink, writeFile } from "fs/promises";
44
import { parseFile } from "music-metadata";
55
import { getFileID, getFileMD5, metaDataLyricsArrayToLrc } from "../utils/helper";
6-
import { File, Picture, Id3v2Settings } from "node-taglib-sharp";
6+
import { File, Picture, Id3v2Settings, TagTypes } from "node-taglib-sharp";
77
import { ipcLog } from "../logger";
88
import { download } from "electron-dl";
99
import { Options as GlobOptions } from "fast-glob/out/settings";
@@ -377,16 +377,17 @@ const initFileIpc = (): void => {
377377
saveMetaFile?: boolean;
378378
lyric?: string;
379379
songData?: any;
380+
skipIfExist?: boolean;
380381
} = {
381382
fileName: "未知文件名",
382383
fileType: "mp3",
383384
path: app.getPath("downloads"),
384385
},
385-
): Promise<boolean> => {
386+
): Promise<{ status: "success" | "skipped" | "error"; message?: string }> => {
386387
try {
387388
// 获取窗口
388389
const win = BrowserWindow.fromWebContents(event.sender);
389-
if (!win) return false;
390+
if (!win) return { status: "error", message: "Window not found" };
390391
// 获取配置
391392
const {
392393
fileName,
@@ -398,6 +399,7 @@ const initFileIpc = (): void => {
398399
downloadLyric,
399400
saveMetaFile,
400401
songData,
402+
skipIfExist,
401403
} = options;
402404
// 规范化路径
403405
const downloadPath = resolve(path);
@@ -407,6 +409,18 @@ const initFileIpc = (): void => {
407409
} catch {
408410
throw new Error("❌ Folder not found");
409411
}
412+
413+
// 检查文件是否存在
414+
if (skipIfExist) {
415+
const filePath = join(downloadPath, `${fileName}.${fileType}`);
416+
try {
417+
await access(filePath);
418+
return { status: "skipped", message: "文件已存在" };
419+
} catch {
420+
// 文件不存在,继续下载
421+
}
422+
}
423+
410424
// 下载文件
411425
const songDownload = await download(win, url, {
412426
directory: downloadPath,
@@ -415,20 +429,29 @@ const initFileIpc = (): void => {
415429
win.webContents.send("download-progress", progress);
416430
},
417431
});
418-
if (!downloadMeta || !songData?.cover) return true;
432+
if (!downloadMeta || !songData?.cover) return { status: "success" };
419433
// 下载封面
420434
const coverUrl = songData?.coverSize?.l || songData.cover;
421435
const coverDownload = await download(win, coverUrl, {
422436
directory: downloadPath,
423437
filename: `${fileName}.jpg`,
424438
});
425439
// 读取歌曲文件
426-
const songFile = File.createFromPath(songDownload.getSavePath());
440+
let songFile = File.createFromPath(songDownload.getSavePath());
441+
// 清除原有标签,防止脏数据(如模拟播放下载时的乱码歌词)
442+
songFile.removeTags(TagTypes.AllTags);
443+
songFile.save();
444+
songFile.dispose();
445+
446+
// 重新读取文件以写入新标签
447+
songFile = File.createFromPath(songDownload.getSavePath());
427448
// 生成图片信息
428449
const songCover = Picture.fromPath(coverDownload.getSavePath());
450+
429451
// 保存修改后的元数据
430452
Id3v2Settings.forceDefaultVersion = true;
431453
Id3v2Settings.defaultVersion = 3;
454+
432455
songFile.tag.title = songData?.name || "未知曲目";
433456
songFile.tag.album = songData?.album?.name || "未知专辑";
434457
songFile.tag.performers = songData?.artists?.map((ar: any) => ar.name) || ["未知艺术家"];
@@ -445,10 +468,10 @@ const initFileIpc = (): void => {
445468
}
446469
// 是否删除封面
447470
if (!saveMetaFile || !downloadCover) await unlink(coverDownload.getSavePath());
448-
return true;
471+
return { status: "success" };
449472
} catch (error) {
450473
ipcLog.error("❌ Error downloading file:", error);
451-
return false;
474+
return { status: "error", message: error instanceof Error ? error.message : "Unknown error" };
452475
}
453476
},
454477
);

src/components/Modal/BatchList.vue

Lines changed: 130 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,52 @@
161161
</n-flex>
162162
</template>
163163
</n-modal>
164+
165+
<!-- 批量下载进度通知 -->
166+
<Teleport to="body">
167+
<Transition name="slide-fade">
168+
<div
169+
v-if="batchDownloadState.isDownloading"
170+
class="batch-download-notification"
171+
:class="{ 'is-hovered': isNotificationHovered }"
172+
@mouseenter="isNotificationHovered = true"
173+
@mouseleave="isNotificationHovered = false"
174+
>
175+
<n-card content-style="padding: 16px;" :bordered="false">
176+
<n-flex vertical :size="12">
177+
<n-flex justify="space-between" align="center">
178+
<n-text strong>批量下载中</n-text>
179+
<n-text depth="3" style="font-size: 12px">
180+
{{ batchDownloadState.processed }}/{{ batchDownloadState.total }}
181+
</n-text>
182+
</n-flex>
183+
184+
<n-text style="font-size: 13px" ellipsis>
185+
正在下载: {{ batchDownloadState.currentSong }}
186+
</n-text>
187+
188+
<n-progress
189+
type="line"
190+
:percentage="batchDownloadState.percent"
191+
:show-indicator="false"
192+
processing
193+
status="success"
194+
style="height: 6px"
195+
/>
196+
197+
<n-flex justify="space-between" style="font-size: 12px">
198+
<n-text depth="3">
199+
{{ batchDownloadState.transferred }} / {{ batchDownloadState.totalSize }}
200+
</n-text>
201+
<n-text depth="3">
202+
成功: {{ batchDownloadState.success }}
203+
</n-text>
204+
</n-flex>
205+
</n-flex>
206+
</n-card>
207+
</div>
208+
</Transition>
209+
</Teleport>
164210
</div>
165211
</template>
166212

@@ -180,6 +226,10 @@ import {
180226
NInputGroup,
181227
NButton,
182228
NAlert,
229+
NCard,
230+
NProgress,
231+
NText,
232+
NFlex,
183233
} from "naive-ui";
184234
import { useLocalStore, useSettingStore } from "@/stores";
185235
import { isElectron } from "@/utils/env";
@@ -219,6 +269,19 @@ const showQualityModal = ref(false);
219269
const selectedQuality = ref<SongLevelType>("h");
220270
const downloadPath = ref<string>(settingStore.downloadPath);
221271
272+
// 批量下载状态
273+
const batchDownloadState = reactive({
274+
isDownloading: false,
275+
total: 0,
276+
processed: 0,
277+
success: 0,
278+
currentSong: "",
279+
percent: 0,
280+
transferred: "0MB",
281+
totalSize: "0MB",
282+
});
283+
const isNotificationHovered = ref(false);
284+
222285
// 音质选项
223286
const qualityOptions = computed(() => {
224287
// 批量下载时,默认显示所有常用音质选项
@@ -293,7 +356,6 @@ const tableCheck = (keys: DataTableRowKey[]) => {
293356
checkSongData.value = selectedRows.map((row) => row.origin).filter((song) => song) as SongType[];
294357
};
295358
296-
// 范围选择处理
297359
// 范围选择处理
298360
const handleRangeSelect = () => {
299361
if (startRange.value === null || endRange.value === null) {
@@ -411,21 +473,24 @@ const startBatchDownload = () => {
411473
const executeBatchDownload = async (songs: SongType[]) => {
412474
if (!songs.length) return;
413475
414-
const total = songs.length;
415-
let processed = 0;
416-
let successCount = 0;
476+
// 重置状态
477+
batchDownloadState.isDownloading = true;
478+
batchDownloadState.total = songs.length;
479+
batchDownloadState.processed = 0;
480+
batchDownloadState.success = 0;
481+
batchDownloadState.percent = 0;
482+
batchDownloadState.transferred = "0MB";
483+
batchDownloadState.totalSize = "0MB";
484+
417485
let failCount = 0;
418486
const failedSongs: SongType[] = [];
419487
420-
const loadingMsg = window.$message.loading(`正在准备批量下载... 0/${total}`, { duration: 0 });
421-
422488
// 监听下载进度
423489
const onProgress = (_event: any, progress: { percent: number; transferredBytes: number; totalBytes: number }) => {
424490
const { percent, transferredBytes, totalBytes } = progress;
425-
const percentStr = (percent * 100).toFixed(0) + "%";
426-
const transferredStr = (transferredBytes / 1024 / 1024).toFixed(2) + "MB";
427-
const totalStr = (totalBytes / 1024 / 1024).toFixed(2) + "MB";
428-
loadingMsg.content = `正在批量下载... ${processed + 1}/${total} (成功 ${successCount}) - ${percentStr} ${transferredStr}/${totalStr}`;
491+
batchDownloadState.percent = Number((percent * 100).toFixed(0));
492+
batchDownloadState.transferred = (transferredBytes / 1024 / 1024).toFixed(2) + "MB";
493+
batchDownloadState.totalSize = (totalBytes / 1024 / 1024).toFixed(2) + "MB";
429494
};
430495
431496
if (isElectron) {
@@ -434,15 +499,24 @@ const executeBatchDownload = async (songs: SongType[]) => {
434499
435500
try {
436501
for (const song of songs) {
502+
batchDownloadState.currentSong = song.name;
437503
try {
438504
const result = await downloadSong({
439505
song,
440506
quality: selectedQuality.value,
441507
downloadPath: downloadPath.value,
508+
skipIfExist: true,
442509
});
443510
444511
if (result.success) {
445-
successCount++;
512+
batchDownloadState.success++;
513+
if (result.skipped) {
514+
window.$notification.create({
515+
title: "已跳过重复文件",
516+
content: `${song.name} 已存在`,
517+
duration: 2000,
518+
});
519+
}
446520
if (!isElectron) {
447521
// Browser download delay
448522
await new Promise((resolve) => setTimeout(resolve, 500));
@@ -457,33 +531,41 @@ const executeBatchDownload = async (songs: SongType[]) => {
457531
failCount++;
458532
failedSongs.push(song);
459533
} finally {
460-
processed++;
461-
loadingMsg.content = `正在批量下载... ${processed}/${total} (成功 ${successCount})`;
534+
batchDownloadState.processed++;
535+
// Reset progress for next song
536+
batchDownloadState.percent = 0;
537+
batchDownloadState.transferred = "0MB";
538+
batchDownloadState.totalSize = "0MB";
462539
}
463540
}
464541
465542
if (failCount > 0) {
466543
window.$dialog.warning({
467544
title: "下载完成,但有部分失败",
468-
content: `${successCount} 首下载成功,${failCount} 首下载失败。是否重试失败的歌曲?`,
545+
content: () => h("div", [
546+
h("div", { style: "margin-bottom: 10px" }, `${batchDownloadState.success} 首下载成功,${failCount} 首下载失败。`),
547+
h("div", { style: "max-height: 200px; overflow-y: auto; background: rgba(0,0,0,0.05); padding: 8px; border-radius: 4px;" }, [
548+
h("div", { style: "font-weight: bold; margin-bottom: 4px" }, "失败列表:"),
549+
...failedSongs.map(s => h("div", { style: "font-size: 12px" }, `${s.name} - ${isArray(s.artists) ? s.artists[0]?.name : s.artists || '未知歌手'}`))
550+
])
551+
]),
469552
positiveText: "重试失败歌曲",
470553
negativeText: "取消",
471554
onPositiveClick: () => {
472555
executeBatchDownload(failedSongs);
473556
},
474557
});
475558
} else {
476-
window.$message.success(`批量下载完成,共 ${successCount} 首`);
559+
window.$message.success(`批量下载完成,共 ${batchDownloadState.success} 首`);
477560
}
478561
} catch (error) {
479562
console.error("Batch download error:", error);
480563
window.$message.error("批量下载过程中出现错误");
481-
window.$message.error("批量下载过程中出现错误");
482564
} finally {
483565
if (isElectron) {
484566
window.electron.ipcRenderer.removeListener("download-progress", onProgress);
485567
}
486-
loadingMsg.destroy();
568+
batchDownloadState.isDownloading = false;
487569
}
488570
};
489571
</script>
@@ -495,4 +577,35 @@ const executeBatchDownload = async (songs: SongType[]) => {
495577
.range-input {
496578
width: 100px;
497579
}
580+
581+
.batch-download-notification {
582+
position: fixed;
583+
bottom: 24px;
584+
right: 24px;
585+
z-index: 9999;
586+
width: 320px;
587+
transition: all 0.3s ease;
588+
589+
&.is-hovered {
590+
opacity: 0;
591+
transform: translateY(10px);
592+
pointer-events: none;
593+
}
594+
595+
:deep(.n-card) {
596+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
597+
border: 1px solid rgba(255, 255, 255, 0.1);
598+
}
599+
}
600+
601+
.slide-fade-enter-active,
602+
.slide-fade-leave-active {
603+
transition: all 0.3s ease;
604+
}
605+
606+
.slide-fade-enter-from,
607+
.slide-fade-leave-to {
608+
transform: translateY(20px);
609+
opacity: 0;
610+
}
498611
</style>

src/components/Modal/DownloadSong.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,6 @@ const changeDownloadPath = async () => {
137137
const download = async () => {
138138
if (!songData.value) return;
139139
loading.value = true;
140-
if (settingStore.downloadPath) downloadPath.value = settingStore.downloadPath;
141140
142141
try {
143142
const result = await downloadSong({

src/components/Setting/LocalSetting.vue

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,18 @@
142142
class="set"
143143
/>
144144
</n-card>
145+
<n-card class="set-item">
146+
<div class="label">
147+
<n-text class="name">模拟播放下载<n-tag type="warning" size="small" round>Beta</n-tag></n-text>
148+
<n-text class="tip" :depth="3">使用播放接口进行下载,可能解决部分下载失败问题</n-text>
149+
</div>
150+
<n-switch
151+
:value="settingStore.usePlaybackForDownload"
152+
:round="false"
153+
class="set"
154+
@update:value="handlePlaybackDownloadChange"
155+
/>
156+
</n-card>
145157
<n-card class="set-item">
146158
<div class="label">
147159
<n-text class="name">保留元信息文件</n-text>
@@ -169,6 +181,23 @@ const choosePath = async () => {
169181
const path = await window.electron.ipcRenderer.invoke("choose-path");
170182
if (path) settingStore.downloadPath = path;
171183
};
184+
185+
// 模拟播放下载开关
186+
const handlePlaybackDownloadChange = (value: boolean) => {
187+
if (value) {
188+
window.$dialog.warning({
189+
title: "开启提示",
190+
content: "模拟播放下载可能导致部分音质歌词嵌入异常且未经完整测试可能有不稳定情况,确认要打开吗?",
191+
positiveText: "确认打开",
192+
negativeText: "取消",
193+
onPositiveClick: () => {
194+
settingStore.usePlaybackForDownload = true;
195+
},
196+
});
197+
} else {
198+
settingStore.usePlaybackForDownload = false;
199+
}
200+
};
172201
</script>
173202

174203
<style lang="scss" scoped>

0 commit comments

Comments
 (0)