Skip to content
Open
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
35 changes: 23 additions & 12 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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,
)
Expand Down
8 changes: 7 additions & 1 deletion src/download/progress/ink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
})

// 成功, 可能会调用多次, 不知为何
Expand Down Expand Up @@ -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
Expand Down
169 changes: 164 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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({
Expand All @@ -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)
}

/**
Expand All @@ -118,20 +133,127 @@ export function getAdapter(url: string) {
return new adapter(url)
}

// 用于跟踪已分配的文件名及其大小,避免重名和内容重复
const generatedFileNames: Map<string, Set<number>> = 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')

/**
* 获取歌曲文件表示
*/
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)

Expand Down Expand Up @@ -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)
}
45 changes: 45 additions & 0 deletions test/debug-filename.test.ts
Original file line number Diff line number Diff line change
@@ -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'))
})
})