diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 6a7bef5..c888b83 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -48,7 +48,7 @@ async function logout() {
- {{ health?.webTorrent ? '磁力解析就绪' : '磁力解析不可用' }} + {{ health?.webTorrent ? '磁力解析就绪' : '磁力解析(缓存模式)' }} diff --git a/app/pages/settings.vue b/app/pages/settings.vue index 6feb93b..a368c99 100644 --- a/app/pages/settings.vue +++ b/app/pages/settings.vue @@ -323,8 +323,8 @@ onMounted(load) {{ health?.mongo.ok ? '已连接' : '未连接' }}
磁力解析
-
- {{ health?.webTorrent ? 'webtorrent 可用,可通过 DHT/tracker 取文件清单' : 'webtorrent 不可用,磁力链只能显示 infoHash' }} +
+ {{ health?.webTorrent ? 'webtorrent 可用,可通过 DHT/tracker 取文件清单' : 'webtorrent 不可用,但通过 torrent 缓存服务在线取清单(不走 DHT)' }}
搜索源
{{ health?.providers?.map((p) => p.label).join(' / ') }}
diff --git a/app/utils/i18n-dict.ts b/app/utils/i18n-dict.ts index bec3459..a37dd11 100644 --- a/app/utils/i18n-dict.ts +++ b/app/utils/i18n-dict.ts @@ -10,7 +10,7 @@ export const zh2en: Record = { '设置': 'Settings', 'MONGO 未连接': 'MONGO disconnected', '磁力解析就绪': 'Magnet parsing ready', - '磁力解析不可用': 'Magnet parsing unavailable', + '磁力解析(缓存模式)': 'Magnet parsing (cache mode)', '本地自托管 · 单进程 Nuxt 单体应用': 'Self-hosted · single-process Nuxt app', '种子只做解析与预览,文件内容由你自己的下载器拉取': 'Torrents are only parsed and previewed; file contents are fetched by your own download client', @@ -215,7 +215,7 @@ export const zh2en: Record = { '已连接': 'Connected', '磁力解析': 'Magnet parsing', 'webtorrent 可用,可通过 DHT/tracker 取文件清单': 'webtorrent available; can fetch file list via DHT/tracker', - 'webtorrent 不可用,磁力链只能显示 infoHash': 'webtorrent unavailable; magnet links can only show infoHash', + 'webtorrent 不可用,但通过 torrent 缓存服务在线取清单(不走 DHT)': 'webtorrent unavailable, but online file list via torrent cache (no DHT)', // ===== 输入框占位符 ===== '输入片名 / 剧名 / 关键词,回车搜索': 'Movie / show / keyword, press Enter to search', diff --git a/server/utils/torrent.ts b/server/utils/torrent.ts index 7331699..9b6bc2b 100644 --- a/server/utils/torrent.ts +++ b/server/utils/torrent.ts @@ -58,6 +58,24 @@ export function withDefaultTrackers(magnet: string, extra: string[] = []): strin return magnet + list.map((t) => `&tr=${encodeURIComponent(t)}`).join('') } +/** 仅 HTTP/HTTPS/WebSocket 的 tracker(不走 UDP/DHT,Serverless 环境也能出网) */ +export const HTTP_TRACKERS = [ + 'wss://tracker.openwebtorrent.com', + 'wss://tracker.btorrent.xyz', + 'http://tracker.opentrackr.org/announce', + 'http://open.acgnloader.live:8484/announce', + 'https://tracker.nanocodes.net/announce' +] + +/** 给磁力链补上 HTTP/WS tracker(不补 UDP,避免 DHT) */ +export function withHttpTrackers(magnet: string): string { + const existing = new Set(trackersInMagnet(magnet)) + const add = HTTP_TRACKERS.filter((t) => !existing.has(t)) + if (!add.length) return magnet + const sep = magnet.includes('?') ? '&' : '?' + return magnet + sep + add.map((t) => `tr=${encodeURIComponent(t)}`).join('&') +} + function toInfoHash(value: unknown): string { if (!value) return '' if (typeof value === 'string') return value.toLowerCase() @@ -188,55 +206,88 @@ export async function checkWebTorrent(): Promise { } } +function extractInfoHash(magnet: string): string { + const m = magnet.match(/xt=urn:btih:([a-z0-9]+)/i) + return m ? m[1].toLowerCase() : '' +} + +/** 通过公开的 torrent 缓存服务取 .torrent(纯 HTTPS,无需 P2P,Vercel 等 Serverless 可用) */ +const TORRENT_CACHES: ((ih: string) => string)[] = [ + (ih) => `https://itorrents.org/torrent/${ih}.torrent`, + (ih) => `https://torcache.net/torrent/${ih}.torrent`, + (ih) => `https://thetorrent.org/${ih}`, + (ih) => `https://btcache.me/torrent/${ih}/download` +] + +async function fetchFromCache(infoHash: string): Promise<{ name: string; files: TorrentFileEntry[]; size: number } | null> { + if (!infoHash) return null + const results = await Promise.allSettled( + TORRENT_CACHES.map(async (fn) => { + const res = await fetch(fn(infoHash), { redirect: 'follow', signal: AbortSignal.timeout(8000) }) + if (!res.ok) throw new Error(`status ${res.status}`) + const buf = Buffer.from(await res.arrayBuffer()) + const info = await parseTorrentBuffer(buf) + if (!info.files?.length) throw new Error('no files') + return { name: info.name, files: info.files, size: info.size } + }) + ) + for (const r of results) if (r.status === 'fulfilled') return r.value + return null +} + /** - * 通过磁力链从 DHT / tracker 拉取种子元数据,从而得到文件清单。 - * 只取元数据,取到后立刻销毁客户端并清理临时目录。 + * 在线获取种子元数据(文件清单)。 + * 优先走 torrent 缓存服务(纯 HTTPS,Vercel 等 Serverless 可用,无需 P2P); + * 缓存未命中时再尝试 webtorrent,但关闭 DHT、仅用 http/wss tracker(需支持 TCP/TLS 的 Node 运行时)。 */ 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 infoHash = extractInfoHash(magnetUri) - const client = new WebTorrent({ dht: true, lsd: false, utp: false }) + // 1) torrent 缓存服务(并发尝试,取第一个成功) + const cached = await fetchFromCache(infoHash) + if (cached) return cached + + // 2) webtorrent:关闭 DHT,仅 http/wss tracker 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 { + 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: false, lsd: false, utp: false }) try { - // webtorrent v3 的 destroy 是回调式 API - await new Promise((resolve) => client.destroy(() => resolve())) - } catch { - /* ignore */ + const torrent = client.add(withHttpTrackers(magnetUri), { path: dir }) + torrent.on('warning', () => {}) + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`获取种子元数据超时(${Math.round(timeoutMs / 1000)}s),可能缺少可用的 HTTP/WS 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) => ({ + 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 { + await new Promise((resolve) => client.destroy(() => resolve())) + } catch { + /* ignore */ + } + rm(dir, { recursive: true, force: true }).catch(() => {}) } - rm(dir, { recursive: true, force: true }).catch(() => {}) + } catch { + // webtorrent 在当前环境不可用(例如 Vercel 无法加载原生模块),忽略,继续降级 } + + throw new Error('无法通过在线方式获取种子元数据:缓存服务未命中,且当前环境不支持 webtorrent(DHT 已禁用,仅 HTTP/WS tracker)。仅显示 infoHash') }