From 9b8d24b20f939fdb885941ca52a0d667eea01a09 Mon Sep 17 00:00:00 2001 From: Arielle <137397992+CelestialDesolation7@users.noreply.github.com> Date: Fri, 25 Jul 2025 00:03:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8B=E8=BD=BD=E5=90=8C?= =?UTF-8?q?=E5=90=8D=E7=9A=84=E4=B8=8D=E5=90=8C=E6=AD=8C=E6=9B=B2=E6=97=B6?= =?UTF-8?q?=E4=BC=9A=E5=8F=91=E7=94=9F=E7=9A=84=E8=A6=86=E7=9B=96=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 35 ++++--- src/download/progress/ink.tsx | 8 +- src/index.ts | 169 +++++++++++++++++++++++++++++++++- test/debug-filename.test.ts | 45 +++++++++ 4 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 test/debug-filename.test.ts diff --git a/src/cli.ts b/src/cli.ts index 84364f1..8e1394c 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,7 @@ import { fromError } from 'zod-validation-error' import { DEFAULT_COOKIE_FILE, readCookie } from '$auth/cookie' import { baseDebug } from '$common' import type { SongValid } from '$define' -import { downloadSong, getAdapter, getFileName } from './index' +import { downloadSong, getAdapter, getFileName, resetFileNameTracker, SKIP_DOWNLOAD } from './index' import type { PackageJson } from 'type-fest' const debug = baseDebug.extend('cli') @@ -267,23 +267,34 @@ async function defaultCommandAction(options: ExpectedArgv) { item.index = String(index + 1).padStart(len, '0') // index, first as 01 }) + // 重置文件名跟踪器,避免重名文件问题 + resetFileNameTracker() + await pmap( keeped, (song) => { // 文件名 const file = getFileName({ format, song, url, name }) + // 如果文件已存在且大小一致,跳过下载 + if (file === SKIP_DOWNLOAD) { + console.log(`${logSymbols.success} 跳过已存在文件: ${song.singer} - ${song.songName}`) + return Promise.resolve() + } // 下载 - return downloadSong({ - url: song.url, - file, - song, - totalLength: keeped.length, - retryTimeout, - retryTimes, - progress, - skipExists, - skipTrial, - }) + if (typeof file === 'string') { + return downloadSong({ + url: song.url, + file, + song, + totalLength: keeped.length, + retryTimeout, + retryTimes, + progress, + skipExists, + skipTrial, + }) + } + return Promise.resolve() }, concurrency, ) diff --git a/src/download/progress/ink.tsx b/src/download/progress/ink.tsx index 6f1a819..e89bf28 100644 --- a/src/download/progress/ink.tsx +++ b/src/download/progress/ink.tsx @@ -6,6 +6,7 @@ import logSymbols from 'log-symbols' import { useEffect, useMemo, useState } from 'react' import { proxy, useSnapshot } from 'valtio' import type { DownloadSongOptions } from '$index' +import { removeFileNameFromTracker } from '../../index' type CompletedItem = DownloadSongOptions & { index: number @@ -26,6 +27,8 @@ const store = proxy<{ completed: CompletedItem[]; running: RunningItem[] }>({ export async function downloadSongWithInk(options: DownloadSongOptions) { const { url, file, song, totalLength, retryTimeout, retryTimes, skipExists, skipTrial } = options + const expectedSize = + song.raw && song.raw.playUrlInfo && song.raw.playUrlInfo.size ? Number(song.raw.playUrlInfo.size) : null renderApp() const index = song.rawIndex @@ -44,6 +47,8 @@ export async function downloadSongWithInk(options: DownloadSongOptions) { if (idx !== -1) store.running.splice(idx, 1) // completed store.completed.push({ ...options, index, ...payload }) + // 判重数据移除 + removeFileNameFromTracker(file, expectedSize) }) // 成功, 可能会调用多次, 不知为何 @@ -71,7 +76,8 @@ export async function downloadSongWithInk(options: DownloadSongOptions) { } if (song.isFreeTrial && skipTrial) { - return moveToComplete({ success: false, skip: true }) + moveToComplete({ success: false, skip: true }) + return } let skip = false diff --git a/src/index.ts b/src/index.ts index fae8789..62e956a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ +import os from 'node:os' import path from 'node:path' import { dl } from 'dl-vampire' import filenamify from 'filenamify' +import fs from 'fs-extra' import logSymbols from 'log-symbols' import type { Song } from '$define' import { AlbumAdapter } from './adapter/album' @@ -63,14 +65,26 @@ export function downloadSong(options: DownloadSongOptions & { progress?: boolean } } +// 移除判重数据 +export function removeFileNameFromTracker(filePath: string, size: number | null) { + if (!size) return + const baseKey = getBaseNameWithoutNumbering(path.basename(filePath, path.extname(filePath))).toLocaleLowerCase('en') + const sizes = generatedFileNames.get(baseKey) + if (sizes) { + sizes.delete(size) + if (sizes.size === 0) generatedFileNames.delete(baseKey) + } +} + export async function downloadSongPlain(options: DownloadSongOptions) { const { url, file, song, totalLength, retryTimeout, retryTimes, skipExists, skipTrial } = options - + const expectedSize = + song.raw && song.raw.playUrlInfo && song.raw.playUrlInfo.size ? Number(song.raw.playUrlInfo.size) : null if (song.isFreeTrial && skipTrial) { console.log(`${logSymbols.warning} ${song.index}/${totalLength} 跳过试听 ${file}`) + removeFileNameFromTracker(file, expectedSize) return } - let skip = false try { ;({ skip } = await dl({ @@ -88,10 +102,11 @@ export async function downloadSongPlain(options: DownloadSongOptions) { } catch (e: any) { console.log(`${logSymbols.error} ${song.index}/${totalLength} 下载失败 ${file}`) console.error(e.stack || e) + removeFileNameFromTracker(file, expectedSize) return } - console.log(`${logSymbols.success} ${song.index}/${totalLength} ${skip ? '下载跳过' : '下载成功'} ${file}`) + removeFileNameFromTracker(file, expectedSize) } /** @@ -118,6 +133,112 @@ export function getAdapter(url: string) { return new adapter(url) } +// 用于跟踪已分配的文件名及其大小,避免重名和内容重复 +const generatedFileNames: Map> = new Map() + +export function resetFileNameTracker() { + generatedFileNames.clear() +} + +function getBaseNameWithoutNumbering(filename: string) { + const ext = path.extname(filename) + let base = path.basename(filename, ext) + base = base.replace(/ \(\d+\)$/, '') + return base +} + +function fileExistsCaseInsensitiveWithSizeCheck(filePath: string, expectedSize: number | null): boolean { + const dir = path.dirname(filePath) + const targetBase = getBaseNameWithoutNumbering(path.basename(filePath)).toLocaleLowerCase('en') + try { + const files = fs.readdirSync(dir) + for (const f of files) { + if (getBaseNameWithoutNumbering(f).toLocaleLowerCase('en') === targetBase) { + const abs = path.join(dir, f) + try { + const stat = fs.statSync(abs) + if (expectedSize && stat.size === expectedSize) { + return true + } + } catch {} + } + } + } catch {} + return false +} + +function handleDuplicateFileName(filePath: string, expectedSize: number | null): string | typeof SKIP_DOWNLOAD { + const isWin = os.platform() === 'win32' + const dir = path.dirname(filePath) + const ext = path.extname(filePath) + const base = path.basename(filePath, ext) + let counter = 0 + let newPath: string + while (true) { + newPath = counter === 0 ? path.join(dir, `${base}${ext}`) : path.join(dir, `${base} (${counter})${ext}`) + // 检查本地和进程内是否有同“本名+大小”文件,若有则直接跳过 + let skip = false + // 本地判重 + if (isWin) { + try { + if (fs.existsSync(newPath)) { + let stat: fs.Stats | undefined + try { + stat = fs.statSync(newPath) + } catch {} + if (expectedSize && stat && stat.size === expectedSize) { + skip = true + } + } + } catch {} + } else { + try { + if (fs.existsSync(newPath)) { + let stat: fs.Stats | undefined + try { + stat = fs.statSync(newPath) + } catch {} + if (expectedSize && stat && stat.size === expectedSize) { + skip = true + } + } + } catch {} + } + // 进程内判重 + const baseKey = getBaseNameWithoutNumbering(path.basename(newPath, ext)).toLocaleLowerCase('en') + const sizes = generatedFileNames.get(baseKey) + if (!skip && sizes && expectedSize && sizes.has(expectedSize)) { + skip = true + } + if (skip) { + return SKIP_DOWNLOAD + } + // 只判定当前 newPath 是否被本地或进程内占用 + let localExists = false + try { + localExists = fs.existsSync(newPath) + } catch { + localExists = false + } + let memExists = false + if (sizes && expectedSize && sizes.has(expectedSize)) { + memExists = true + } + if (!localExists && !memExists) { + // 记录到进程内Map + if (expectedSize) { + if (!generatedFileNames.has(baseKey)) generatedFileNames.set(baseKey, new Set()) + generatedFileNames.get(baseKey)!.add(expectedSize) + } + return newPath + } + counter++ + } +} + +// 跳过下载的特殊标记 +export const SKIP_DOWNLOAD = Symbol('SKIP_DOWNLOAD') + /** * 获取歌曲文件表示 */ @@ -125,13 +246,14 @@ export function getFileName({ format, song, url, - // 专辑 or playlist 名称 name, + checkSkipExists = true, }: { format: string song: Song url: string name: string + checkSkipExists?: boolean }) { const adapterItem = getType(url) @@ -173,5 +295,42 @@ export function getFileName({ format = path.join(dir, `${base} [试听]${ext}`) } - return format + // 检查本名+大小是否已存在,若是则跳过下载,否则始终走 handleDuplicateFileName + if (checkSkipExists) { + try { + const absPath = path.resolve(format) + const isWin = os.platform() === 'win32' + const expectedSize = + song.raw && song.raw.playUrlInfo && song.raw.playUrlInfo.size ? Number(song.raw.playUrlInfo.size) : null + let skip = false + if (isWin) { + skip = fileExistsCaseInsensitiveWithSizeCheck(absPath, expectedSize) + } else if (fs.existsSync(absPath)) { + let stat: fs.Stats | undefined + try { + stat = fs.statSync(absPath) + } catch {} + if (expectedSize && stat && stat.size === expectedSize) { + skip = true + } + } + // 检查当前进程内已分配的文件名(generatedFileNames Map) + if (!skip && expectedSize) { + const targetBase = getBaseNameWithoutNumbering(path.basename(absPath)).toLocaleLowerCase('en') + const sizes = generatedFileNames.get(targetBase) + if (sizes && sizes.has(expectedSize)) { + skip = true + } + } + if (skip) { + return SKIP_DOWNLOAD + } + } catch { + // ignore + } + } + // 只要没跳过,始终走 handleDuplicateFileName,确保同名不同大小的文件自动编号 + const expectedSize = + song.raw && song.raw.playUrlInfo && song.raw.playUrlInfo.size ? Number(song.raw.playUrlInfo.size) : null + return handleDuplicateFileName(format, expectedSize) } diff --git a/test/debug-filename.test.ts b/test/debug-filename.test.ts new file mode 100644 index 0000000..a41bdd0 --- /dev/null +++ b/test/debug-filename.test.ts @@ -0,0 +1,45 @@ +import path from 'node:path' +import { beforeEach, describe, expect, it } from 'vitest' +import { getFileName, resetFileNameTracker } from '../src/index' +import type { Song } from '../src/define' + +describe('Debug filename generation', () => { + beforeEach(() => { + resetFileNameTracker() + }) + + it('should debug filename generation', () => { + const format = ':name/:songName.:ext' + const url = 'https://music.163.com/#/playlist?id=123' + const name = 'Test Playlist' + + const song1: Song = { + singer: 'Artist A', + songName: 'Same Song', + albumName: 'Album 1', + index: '01', + rawIndex: 0, + ext: 'mp3', + } + + const song2: Song = { + singer: 'Artist B', + songName: 'Same Song', + albumName: 'Album 2', + index: '02', + rawIndex: 1, + ext: 'mp3', + } + + const filename1 = getFileName({ format, song: song1, url, name }) + const filename2 = getFileName({ format, song: song2, url, name }) + + console.log('Filename 1:', filename1) + console.log('Filename 2:', filename2) + + // 使用 path.normalize 来处理路径分隔符 + + expect(path.normalize(filename1 as string)).toBe(path.normalize('Test Playlist/Same Song.mp3')) + expect(path.normalize(filename2 as string)).toBe(path.normalize('Test Playlist/Same Song (1).mp3')) + }) +})