feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署

This commit is contained in:
ice-a
2026-10-16 18:53:45 +08:00
commit d6999104bf
64 changed files with 16524 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import { tryMongo } from '../../utils/mongo'
import { getTorrent } from '../../utils/store'
import { parseMagnet } from '../../utils/torrent'
export default defineEventHandler(async (event) => {
const infoHash = String(getRouterParam(event, 'infohash') || '').toLowerCase()
if (!/^[a-f0-9]{40}$/.test(infoHash)) {
throw createError({ statusCode: 400, statusMessage: 'infoHash 格式不正确(应为 40 位 hex)' })
}
const doc = await tryMongo(() => getTorrent(infoHash))
if (doc) {
const { fileBase64, ...rest } = doc
return { torrent: { ...rest, hasFile: !!fileBase64 }, source: 'database' as const }
}
const magnet = String(getQuery(event).magnet || '').trim()
if (magnet) {
try {
return { torrent: await parseMagnet(magnet), source: 'query' as const }
} catch (err) {
throw createError({ statusCode: 400, statusMessage: err instanceof Error ? err.message : '磁力链解析失败' })
}
}
throw createError({ statusCode: 404, statusMessage: '该种子不在库中(MongoDB 未连接或未收录),请在链接上带上 magnet 参数' })
})
+25
View File
@@ -0,0 +1,25 @@
import { tryMongo } from '../../../utils/mongo'
import { getTorrent } from '../../../utils/store'
/** 下载用户上传时的原始 .torrent 文件 */
export default defineEventHandler(async (event) => {
const infoHash = String(getRouterParam(event, 'infohash') || '').toLowerCase()
if (!/^[a-f0-9]{40}$/.test(infoHash)) {
throw createError({ statusCode: 400, statusMessage: 'infoHash 格式不正确' })
}
const doc = await tryMongo(() => getTorrent(infoHash))
if (!doc?.fileBase64) {
throw createError({ statusCode: 404, statusMessage: '没有保存该种子的原始文件(只有手动上传的种子会保存)' })
}
const bytes = Buffer.from(doc.fileBase64, 'base64')
setHeader(event, 'content-type', 'application/x-bittorrent')
setHeader(event, 'content-length', String(bytes.length))
setHeader(
event,
'content-disposition',
`attachment; filename="${encodeURIComponent((doc.name || infoHash).slice(0, 120))}.torrent"`
)
return bytes
})
@@ -0,0 +1,83 @@
import { loadSettings } from '../../../utils/appconfig'
import { withSwarm } from '../../../utils/swarm'
const IMAGE_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
avif: 'image/avif'
}
/** 超过这个大小就不做在线预览了,从 swarm 拉一张大图的代价太高 */
const MAX_PREVIEW_BYTES = 12 * 1024 * 1024
/** 单张图片的下载时间上限 */
const DOWNLOAD_TIMEOUT_MS = 25_000
function normPath(value: string): string {
return value.replace(/\\/g, '/').replace(/^\.\//, '')
}
/**
* 从 swarm 里只把某一张图片的字节拉下来用于预览。
* 只下载该文件覆盖到的分片,不落库、不长期占用磁盘。
*/
export default defineEventHandler(async (event) => {
const infoHash = String(getRouterParam(event, 'infohash') || '').toLowerCase()
if (!/^[a-f0-9]{40}$/.test(infoHash)) {
throw createError({ statusCode: 400, statusMessage: 'infoHash 格式不正确' })
}
const query = getQuery(event)
const magnet = String(query.magnet || '')
const filePath = String(query.path || '')
if (!/^magnet:\?/i.test(magnet)) {
throw createError({ statusCode: 400, statusMessage: '缺少可用的磁力链,无法定位做种节点' })
}
if (!filePath) {
throw createError({ statusCode: 400, statusMessage: '缺少 path 参数' })
}
const ext = filePath.slice(filePath.lastIndexOf('.') + 1).toLowerCase()
const mime = IMAGE_TYPES[ext]
if (!mime) {
throw createError({ statusCode: 400, statusMessage: '只支持预览图片文件(jpg / png / gif / webp / bmp / avif)' })
}
const settings = await loadSettings()
const bytes = await withSwarm(infoHash, magnet, settings.metadataTimeout, async (torrent) => {
const target = (torrent.files || []).find(
(f: any) => normPath(String(f.path || f.name || '')) === normPath(filePath)
)
if (!target) {
throw createError({ statusCode: 404, statusMessage: '这个种子里没有该文件,文件清单可能已过期' })
}
if (target.length > MAX_PREVIEW_BYTES) {
throw createError({
statusCode: 413,
statusMessage: `这张图 ${Math.round(target.length / 1024 / 1024)} MB,超过在线预览上限 ${MAX_PREVIEW_BYTES / 1024 / 1024} MB`
})
}
return Promise.race<ArrayBuffer>([
target.arrayBuffer(),
new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error('下载图片超时:暂时没有可用的做种节点,稍后再试')),
DOWNLOAD_TIMEOUT_MS
)
})
])
})
setHeader(event, 'content-type', mime)
setHeader(event, 'content-length', String(bytes.byteLength))
setHeader(
event,
'content-disposition',
`inline; filename="${encodeURIComponent(filePath.slice(filePath.lastIndexOf('/') + 1))}"`
)
setHeader(event, 'cache-control', 'private, max-age=3600')
return Buffer.from(bytes)
})
@@ -0,0 +1,30 @@
import { loadSettings } from '../../../utils/appconfig'
import { tryMongo } from '../../../utils/mongo'
import { getTorrent, markResolved } from '../../../utils/store'
import { fetchMetadataFromMagnet } from '../../../utils/torrent'
export default defineEventHandler(async (event) => {
const infoHash = String(getRouterParam(event, 'infohash') || '').toLowerCase()
if (!/^[a-f0-9]{40}$/.test(infoHash)) {
throw createError({ statusCode: 400, statusMessage: 'infoHash 格式不正确(应为 40 位 hex)' })
}
const body = await readBody(event).catch(() => ({}))
const doc = await tryMongo(() => getTorrent(infoHash))
const magnet = String(body?.magnet || doc?.magnet || '').trim()
if (!magnet) {
throw createError({ statusCode: 400, statusMessage: '缺少磁力链,无法在线获取文件清单' })
}
const settings = await loadSettings()
try {
const meta = await fetchMetadataFromMagnet(magnet, settings.metadataTimeout)
if (!meta.files.length) throw new Error('已连上节点,但对方没有返回任何文件信息')
await tryMongo(() => markResolved(infoHash, { files: meta.files, size: meta.size, name: meta.name || doc?.name }, null))
return { infoHash, name: meta.name, files: meta.files, size: meta.size }
} catch (err) {
const message = err instanceof Error ? err.message : '获取种子元数据失败'
await tryMongo(() => markResolved(infoHash, null, message))
throw createError({ statusCode: 502, statusMessage: message })
}
})