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')
}