Files
bt/server/utils/torrent.ts
T

243 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import parseTorrent, { toMagnetURI } from 'parse-torrent'
import type { TorrentFileEntry } from './types'
export interface TorrentInfo {
infoHash: string
name: string
size: number
files: TorrentFileEntry[] | null
fileCount: number | null
trackers: string[]
magnet: string
comment: string | null
creationDate: Date | null
pieceLength: number | null
isPrivate: boolean
}
export const DEFAULT_TRACKERS = [
'udp://tracker.opentrackr.org:1337/announce',
'udp://open.tracker.cl:1337/announce',
'udp://open.demonii.com:1337/announce',
'udp://tracker.openbittorrent.com:6969/announce',
'udp://exodus.desync.com:6969/announce',
'udp://tracker.torrent.eu.org:451/announce'
]
export function buildMagnet(infoHash: string, name?: string | null, trackers: string[] = DEFAULT_TRACKERS): string {
const params = [`xt=urn:btih:${infoHash}`]
if (name) params.push(`dn=${encodeURIComponent(name)}`)
for (const tr of trackers) params.push(`tr=${encodeURIComponent(tr)}`)
return `magnet:?${params.join('&')}`
}
/** 从磁力链里取出已有的 tracker */
function trackersInMagnet(magnet: string): string[] {
const out = new Set<string>()
for (const m of magnet.matchAll(/[?&]tr=([^&]+)/g)) {
try {
out.add(decodeURIComponent(m[1]))
} catch {
/* 编码坏了就跳过 */
}
}
return [...out]
}
/**
* 给没有 tracker 的磁力链补上 tracker。
* 社区里流传的裸磁力链(只有 xt + dn)常常一个 tracker 都没有,
* 纯靠 DHT 在家庭网络/被墙环境里几乎拉不到元数据,补公共 tracker 能显著提高成功率。
*/
export function withDefaultTrackers(magnet: string, extra: string[] = []): string {
if (!magnet || /[?&]tr=/.test(magnet)) return magnet
const list = extra.length ? extra : DEFAULT_TRACKERS
return magnet + list.map((t) => `&tr=${encodeURIComponent(t)}`).join('')
}
function toInfoHash(value: unknown): string {
if (!value) return ''
if (typeof value === 'string') return value.toLowerCase()
if (value instanceof Uint8Array || Buffer.isBuffer(value)) return Buffer.from(value as Uint8Array).toString('hex')
return String(value).toLowerCase()
}
function pickTrackers(parsed: Record<string, unknown>): string[] {
const out = new Set<string>()
const announce = parsed.announce
if (typeof announce === 'string') out.add(announce)
else if (Array.isArray(announce)) for (const a of announce) if (typeof a === 'string') out.add(a)
const list = parsed.announceList
if (Array.isArray(list)) {
for (const tier of list) {
if (Array.isArray(tier)) for (const a of tier) if (typeof a === 'string') out.add(a)
}
}
return [...out]
}
function normalize(parsed: Record<string, any>, magnetFallback?: string): TorrentInfo {
const infoHash = toInfoHash(parsed.infoHash)
let files: TorrentFileEntry[] | null = null
if (Array.isArray(parsed.files) && parsed.files.length) {
files = parsed.files.map((f: any) => ({
// parse-torrent 用 path.sep 拼接,Windows 下是反斜杠,统一成正斜杠便于展示
path: String(f.path ?? f.name ?? '').replace(/\\/g, '/'),
length: Number(f.length) || 0
}))
} else if (typeof parsed.length === 'number') {
// 单文件种子:parse-torrent 只给出 length + name
files = [{ path: String(parsed.name || 'unknown'), length: Number(parsed.length) || 0 }]
}
const size = files ? files.reduce((sum, f) => sum + (f.length || 0), 0) : Number(parsed.length) || 0
let trackers = pickTrackers(parsed)
let magnet = magnetFallback || ''
if (!magnet) {
try {
magnet = toMagnetURI(parsed as never)
} catch {
magnet = infoHash ? buildMagnet(infoHash, parsed.name, trackers.length ? trackers : DEFAULT_TRACKERS) : ''
}
}
magnet = withDefaultTrackers(magnet, trackers)
// 补过 tracker 的磁力链,trackers 以最终磁力链为准,避免 UI 显示“无 tracker”
if (!trackers.length) trackers = trackersInMagnet(magnet)
return {
infoHash,
name: String(parsed.name || ''),
size,
files,
fileCount: files ? files.length : null,
trackers,
magnet,
comment: typeof parsed.comment === 'string' ? parsed.comment : null,
creationDate: parsed.created instanceof Date
? parsed.created
: parsed.creationDate instanceof Date
? parsed.creationDate
: null,
pieceLength: typeof parsed.pieceLength === 'number' ? parsed.pieceLength : null,
isPrivate: parsed.private === true
}
}
/** 解析 .torrent 文件内容(parse-torrent v11 起为异步 API) */
export async function parseTorrentBuffer(buf: Buffer): Promise<TorrentInfo> {
let parsed: any
try {
parsed = await parseTorrent(buf)
} catch {
throw new Error('种子文件解析失败,可能不是合法的 .torrent 文件')
}
const info = normalize(parsed)
if (!info.infoHash) throw new Error('种子文件中缺少 info 字段,无法计算 infoHash')
return info
}
/** 解析磁力链(不含文件清单,需要通过 fetchMetadataFromMagnet 才能拿到) */
export async function parseMagnet(uri: string): Promise<TorrentInfo> {
const raw = uri.trim()
if (!/^magnet:\?/i.test(raw)) throw new Error('不是合法的磁力链接,应以 magnet:? 开头')
if (!/xt=urn:btih:[a-z0-9]+/i.test(raw)) throw new Error('磁力链接中缺少 xt=urn:btih: 参数')
let parsed: any
try {
parsed = await parseTorrent(raw)
} catch {
throw new Error('磁力链接解析失败')
}
const info = normalize(parsed, raw)
if (!info.infoHash) throw new Error('无法从磁力链接中提取 infoHash')
return info
}
/** 判断字符串是否为磁力链 */
export function isMagnet(input: string): boolean {
return /^magnet:\?/i.test(input.trim())
}
let webTorrentMod: unknown = null
export async function loadWebTorrent(): Promise<any> {
if (webTorrentMod) return webTorrentMod
try {
const mod: any = await import('webtorrent')
webTorrentMod = mod?.default || mod
return webTorrentMod
} catch {
throw new Error('未安装 webtorrent 依赖,无法通过磁力链在线获取文件清单(仅能显示 infoHash)')
}
}
export function hasWebTorrent(): boolean {
return webTorrentMod !== null
}
/** 探测 webtorrent 依赖是否可用(用于健康检查与 UI 提示) */
export async function checkWebTorrent(): Promise<boolean> {
try {
await loadWebTorrent()
return true
} catch {
return false
}
}
/**
* 通过磁力链从 DHT / tracker 拉取种子元数据,从而得到文件清单。
* 只取元数据,取到后立刻销毁客户端并清理临时目录。
*/
export async function fetchMetadataFromMagnet(
magnetUri: string,
timeoutMs = 30000
): Promise<{ name: string; files: TorrentFileEntry[]; size: number }> {
const WebTorrent = await loadWebTorrent()
const dir = join(tmpdir(), `dsl-meta-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`)
await mkdir(dir, { recursive: true })
const client = new WebTorrent({ dht: true, lsd: false, utp: false })
try {
// 纯 DHT 很可能找不到节点,没 tracker 的磁力链先补上公共 tracker
const torrent = client.add(withDefaultTrackers(magnetUri), { path: dir })
torrent.on('warning', () => {})
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`获取种子元数据超时(${Math.round(timeoutMs / 1000)}s),可能没有可用节点或 tracker 被墙`))
}, timeoutMs)
const done = (fn: () => void) => {
clearTimeout(timer)
fn()
}
if (torrent.metadata) return done(resolve)
torrent.on('metadata', () => done(resolve))
torrent.on('error', (err: Error) => done(() => reject(err)))
client.on('error', (err: Error) => done(() => reject(err)))
})
const files: TorrentFileEntry[] = (torrent.files || []).map((f: any) => ({
// Windows 下 webtorrent 用反斜杠拼路径,统一成正斜杠便于展示
path: String(f.path || f.name || '').replace(/\\/g, '/'),
length: Number(f.length) || 0
}))
return {
name: String(torrent.name || ''),
files,
size: files.reduce((sum, f) => sum + f.length, 0)
}
} finally {
try {
// webtorrent v3 的 destroy 是回调式 API
await new Promise<void>((resolve) => client.destroy(() => resolve()))
} catch {
/* ignore */
}
rm(dir, { recursive: true, force: true }).catch(() => {})
}
}