feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user