feat: 文件清单与图片预览的 P2P 部分挪到浏览器端(WebRTC),服务端仅缓存兜底
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TorrentFileEntry } from '~/types/api'
|
import type { TorrentFileEntry } from '~/types/api'
|
||||||
|
import { readFileBytesClient } from '~/utils/clientTorrent'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
infoHash: string
|
infoHash: string
|
||||||
@@ -16,6 +17,20 @@ const errorText = ref('')
|
|||||||
const src = ref('')
|
const src = ref('')
|
||||||
let objectUrl = ''
|
let objectUrl = ''
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
png: 'image/png',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
avif: 'image/avif'
|
||||||
|
}
|
||||||
|
function extOf(p: string): string {
|
||||||
|
const i = p.lastIndexOf('.')
|
||||||
|
return i >= 0 ? p.slice(i + 1).toLowerCase() : ''
|
||||||
|
}
|
||||||
|
|
||||||
const current = computed<TorrentFileEntry | undefined>(() => props.images[props.index])
|
const current = computed<TorrentFileEntry | undefined>(() => props.images[props.index])
|
||||||
const requestUrl = computed(
|
const requestUrl = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -36,10 +51,18 @@ async function load() {
|
|||||||
src.value = ''
|
src.value = ''
|
||||||
errorText.value = ''
|
errorText.value = ''
|
||||||
status.value = 'loading'
|
status.value = 'loading'
|
||||||
|
try {
|
||||||
|
// 优先在用户浏览器里用 WebRTC 从 P2P 网络直接拉取图片字节(不经过 Vercel/Serverless)
|
||||||
|
const bytes = await readFileBytesClient(props.magnet, current.value?.path || '', 25000)
|
||||||
|
const mime = MIME[extOf(current.value?.path || '')] || 'application/octet-stream'
|
||||||
|
objectUrl = URL.createObjectURL(new Blob([bytes], { type: mime }))
|
||||||
|
src.value = objectUrl
|
||||||
|
status.value = 'ok'
|
||||||
|
} catch {
|
||||||
|
// 回退到服务端预览(自托管环境可用;Serverless 上也会失败,此时给出可读错误)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(requestUrl.value)
|
const res = await fetch(requestUrl.value)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// 服务端用 statusMessage 带上可读原因:没有做种节点 / 图片过大 / 文件不存在
|
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
let message = text
|
let message = text
|
||||||
try {
|
try {
|
||||||
@@ -58,6 +81,7 @@ async function load() {
|
|||||||
status.value = 'err'
|
status.value = 'err'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function step(delta: number) {
|
function step(delta: number) {
|
||||||
const next = props.index + delta
|
const next = props.index + delta
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TorrentFileEntry, TorrentView } from '~/types/api'
|
import type { TorrentFileEntry, TorrentView } from '~/types/api'
|
||||||
|
import { resolveMetadataClient } from '~/utils/clientTorrent'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
torrent: TorrentView
|
torrent: TorrentView
|
||||||
@@ -49,24 +50,36 @@ const sourceIcon: Record<string, string> = {
|
|||||||
database: '数据库'
|
database: '数据库'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doResolve() {
|
function applyResolved(res: { files: TorrentFileEntry[]; size: number; name: string }) {
|
||||||
resolving.value = true
|
|
||||||
try {
|
|
||||||
const res = await $fetch<{ files: TorrentFileEntry[]; size: number; name: string }>(
|
|
||||||
`/api/torrent/${local.value.infoHash}/resolve`,
|
|
||||||
{ method: 'POST', body: { magnet: local.value.magnet } }
|
|
||||||
)
|
|
||||||
local.value.files = res.files
|
local.value.files = res.files
|
||||||
local.value.size = res.size
|
local.value.size = res.size
|
||||||
local.value.fileCount = res.files.length
|
local.value.fileCount = res.files.length
|
||||||
local.value.resolveError = null
|
local.value.resolveError = null
|
||||||
if (res.name) local.value.name = res.name
|
if (res.name) local.value.name = res.name
|
||||||
emit('resolved', res)
|
emit('resolved', res)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doResolve() {
|
||||||
|
resolving.value = true
|
||||||
|
try {
|
||||||
|
// 服务端先尝试(缓存服务取清单,自托管还会走 P2P),成功即返回并落库
|
||||||
|
const res = await $fetch<{ files: TorrentFileEntry[]; size: number; name: string }>(
|
||||||
|
`/api/torrent/${local.value.infoHash}/resolve`,
|
||||||
|
{ method: 'POST', body: { magnet: local.value.magnet } }
|
||||||
|
)
|
||||||
|
applyResolved(res)
|
||||||
pushToast('ok', '文件清单已解析', `共 ${res.files.length} 个文件 / ${formatBytes(res.size)}`)
|
pushToast('ok', '文件清单已解析', `共 ${res.files.length} 个文件 / ${formatBytes(res.size)}`)
|
||||||
} catch (err) {
|
} catch {
|
||||||
const msg = errorMessage(err)
|
// 服务端无法走 P2P(如 Vercel/Serverless)→ 回退到浏览器端 WebRTC 直接解析
|
||||||
|
try {
|
||||||
|
const meta = await resolveMetadataClient(local.value.magnet, 30000)
|
||||||
|
applyResolved(meta)
|
||||||
|
pushToast('ok', '通过浏览器 P2P 解析文件清单', `共 ${meta.files.length} 个文件 / ${formatBytes(meta.size)}`)
|
||||||
|
} catch (err2) {
|
||||||
|
const msg = errorMessage(err2)
|
||||||
local.value.resolveError = msg
|
local.value.resolveError = msg
|
||||||
pushToast('err', '解析失败', msg)
|
pushToast('err', '解析失败', msg)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
resolving.value = false
|
resolving.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// 浏览器端 WebTorrent(WebRTC):把 P2P 流量放到用户自己的设备,
|
||||||
|
// 绕开 Vercel / Cloudflare 等 Serverless 不支持 UDP/DHT 的限制。
|
||||||
|
// 服务端仍保留非 P2P 的缓存服务兜底;这里是真正的 P2P 取数据路径。
|
||||||
|
|
||||||
|
// 浏览器只支持 WebRTC 节点,需要走 wss:// 的 tracker 才能发现对端
|
||||||
|
const WS_TRACKERS = [
|
||||||
|
'wss://tracker.openwebtorrent.com',
|
||||||
|
'wss://tracker.btorrent.xyz',
|
||||||
|
'wss://tracker.webtorrent.dev'
|
||||||
|
]
|
||||||
|
|
||||||
|
function normPath(p: string): string {
|
||||||
|
return p.replace(/\\/g, '/').replace(/^\.\//, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function withWsTrackers(magnet: string): string {
|
||||||
|
const existing = new Set<string>()
|
||||||
|
for (const m of magnet.matchAll(/[?&]tr=([^&]+)/g)) {
|
||||||
|
try {
|
||||||
|
existing.add(decodeURIComponent(m[1]))
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const add = WS_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('&')
|
||||||
|
}
|
||||||
|
|
||||||
|
let wtPromise: Promise<any> | null = null
|
||||||
|
function loadWebTorrent(): Promise<any> {
|
||||||
|
if (!wtPromise) {
|
||||||
|
// 仅在浏览器运行时按需加载,Vite 会将其单独拆包;服务端不会执行到此处
|
||||||
|
wtPromise = import('webtorrent').then((m) => (m as any).default || m)
|
||||||
|
}
|
||||||
|
return wtPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForMetadata(torrent: any, timeoutMs: number): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (torrent.metadata || torrent.ready) return resolve()
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => reject(new Error('浏览器 P2P 获取元数据超时,可能该种子没有 WebRTC 节点')),
|
||||||
|
timeoutMs
|
||||||
|
)
|
||||||
|
const done = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
torrent.on('metadata', done)
|
||||||
|
torrent.on('ready', done)
|
||||||
|
torrent.on('error', (e: Error) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(e)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveMetadataClient(
|
||||||
|
magnet: string,
|
||||||
|
timeoutMs = 30000
|
||||||
|
): Promise<{ name: string; files: { path: string; length: number }[]; size: number }> {
|
||||||
|
const WebTorrent = await loadWebTorrent()
|
||||||
|
const client = new WebTorrent()
|
||||||
|
try {
|
||||||
|
const torrent = client.add(withWsTrackers(magnet))
|
||||||
|
await waitForMetadata(torrent, timeoutMs)
|
||||||
|
const files = (torrent.files || []).map((f: any) => ({
|
||||||
|
path: normPath(String(f.path || f.name || '')),
|
||||||
|
length: Number(f.length) || 0
|
||||||
|
}))
|
||||||
|
if (!files.length) throw new Error('已连上节点,但对方没有返回任何文件信息')
|
||||||
|
return { name: String(torrent.name || ''), files, size: files.reduce((s, f) => s + f.length, 0) }
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
client.destroy()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readFileBytesClient(
|
||||||
|
magnet: string,
|
||||||
|
filePath: string,
|
||||||
|
timeoutMs = 25000
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
const WebTorrent = await loadWebTorrent()
|
||||||
|
const client = new WebTorrent()
|
||||||
|
try {
|
||||||
|
const torrent = client.add(withWsTrackers(magnet))
|
||||||
|
await waitForMetadata(torrent, timeoutMs)
|
||||||
|
const target = (torrent.files || []).find(
|
||||||
|
(f: any) => normPath(String(f.path || f.name || '')) === normPath(filePath)
|
||||||
|
)
|
||||||
|
if (!target) throw new Error('这个种子里没有该文件,文件清单可能已过期')
|
||||||
|
return await target.arrayBuffer()
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
client.destroy()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isClientWebTorrentAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await loadWebTorrent()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import nodePolyfills from 'vite-plugin-node-polyfills'
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: false },
|
devtools: { enabled: false },
|
||||||
@@ -27,6 +29,12 @@ export default defineNuxtConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
vite: {
|
vite: {
|
||||||
|
plugins: [
|
||||||
|
nodePolyfills({
|
||||||
|
globals: { Buffer: true, global: true, process: true },
|
||||||
|
protocolImports: true
|
||||||
|
})
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
fs: { strict: false }
|
fs: { strict: false }
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.0",
|
"@types/node": "^22.13.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3",
|
||||||
|
"vite-plugin-node-polyfills": "^0.23.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user