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() 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[] { const out = new Set() 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, 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 { 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 { 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 { if (webTorrentMod) return webTorrentMod try { const mod: any = await import('webtorrent') webTorrentMod = mod?.default || mod return webTorrentMod } catch { throw new Error('无法在当前运行环境加载 webtorrent(需要支持 UDP 的 Node 运行时;Vercel / 其他 Serverless 通常不可用),仅能显示 infoHash') } } export function hasWebTorrent(): boolean { return webTorrentMod !== null } /** 探测 webtorrent 依赖是否可用(用于健康检查与 UI 提示) */ export async function checkWebTorrent(): Promise { 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((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((resolve) => client.destroy(() => resolve())) } catch { /* ignore */ } rm(dir, { recursive: true, force: true }).catch(() => {}) } }