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
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import type { TorrentFileEntry, TorrentView } from '~/types/api'
const props = defineProps<{
torrent: TorrentView
/** 是否允许在线解析磁力链文件清单 */
canResolve?: boolean
}>()
const emit = defineEmits<{ resolved: [payload: { files: TorrentFileEntry[]; size: number; name: string }] }>()
const local = ref<TorrentView>({ ...props.torrent })
const resolving = ref(false)
const showTrackers = ref(false)
watch(
() => props.torrent,
(next) => {
local.value = { ...next }
},
{ deep: true }
)
const files = computed(() => local.value.files)
const total = computed(() => files.value?.reduce((s, f) => s + f.length, 0) || local.value.size || 0)
/** 可在线预览的图片:按文件清单原始顺序,预览弹窗靠这个顺序翻页 */
const previewImages = computed<TorrentFileEntry[]>(() =>
(files.value || []).filter((f) => f.length > 0 && f.length <= MAX_IMAGE_PREVIEW_BYTES && isImagePath(f.path))
)
const previewIndex = ref<number | null>(null)
function openPreview(file: TorrentFileEntry) {
const index = previewImages.value.findIndex((f) => f.path === file.path)
if (index >= 0) previewIndex.value = index
}
// 文件清单变了(例如刚在线解析完),旧的索引可能已经失效
watch(files, () => {
previewIndex.value = null
})
const sourceIcon: Record<string, string> = {
apibay: 'The Pirate Bay',
nyaa: 'Nyaa',
cilisousuo: '磁力搜索',
upload: '本地上传',
magnet: '磁力链',
database: '数据库'
}
async function doResolve() {
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.size = res.size
local.value.fileCount = res.files.length
local.value.resolveError = null
if (res.name) local.value.name = res.name
emit('resolved', res)
pushToast('ok', '文件清单已解析', `共 ${res.files.length} 个文件 / ${formatBytes(res.size)}`)
} catch (err) {
const msg = errorMessage(err)
local.value.resolveError = msg
pushToast('err', '解析失败', msg)
} finally {
resolving.value = false
}
}
async function copyMagnet() {
const ok = await copyText(local.value.magnet)
pushToast(ok ? 'ok' : 'err', ok ? '磁力链已复制到剪贴板' : '复制失败,请手动选中复制')
}
onMounted(() => {
// 如果没有文件清单,且是磁力链接(有 magnet),则自动尝试解析
if (!local.value.files && local.value.magnet && props.canResolve !== false) {
doResolve()
}
})
</script>
<template>
<div class="stack">
<div class="panel">
<div class="panel-hd">
<span class="label">TORRENT</span>
<span class="mono small truncate" style="max-width: 46ch" :title="local.name">{{ local.name }}</span>
<span class="spacer" />
<span class="tag">{{ sourceIcon[local.source || ''] || local.source || '未知来源' }}</span>
<span v-if="local.category" class="tag acc">{{ local.category }}</span>
</div>
<div class="panel-bd stack">
<div class="grid-4">
<div class="stat">
<span class="label">总大小</span>
<span class="v">{{ formatBytes(total) }}</span>
</div>
<div class="stat">
<span class="label">文件数</span>
<span class="v">{{ local.files ? formatNumber(local.files.length) : local.fileCount ?? '未知' }}</span>
</div>
<div class="stat">
<span class="label">做种 / 下载</span>
<span class="v">
<span class="ok">{{ local.seeders ?? '—' }}</span>
<span class="mute"> / </span>
<span class="warn">{{ local.leechers ?? '—' }}</span>
</span>
</div>
<div class="stat">
<span class="label">发布时间</span>
<span class="v small">{{ local.creationDate ? formatDate(local.creationDate) : '—' }}</span>
</div>
</div>
<dl class="kv">
<dt>infoHash</dt>
<dd class="mono break small">{{ local.infoHash }}</dd>
<dt>Trackers</dt>
<dd>
<button class="btn btn-sm btn-ghost" @click="showTrackers = !showTrackers">
{{ local.trackers?.length || 0 }} 个{{ showTrackers ? ' · 收起' : ' · 展开' }}
</button>
<div v-if="showTrackers" class="stack sm mt" style="max-height: 180px; overflow: auto">
<span v-for="t in local.trackers" :key="t" class="mono small dim break">{{ t }}</span>
</div>
</dd>
<dt v-if="local.comment">备注</dt>
<dd v-if="local.comment" class="small dim">{{ local.comment }}</dd>
</dl>
<div class="row wrap">
<button class="btn btn-sm" @click="copyMagnet">复制磁力链</button>
<a v-if="local.hasFile" class="btn btn-sm" :href="`/api/torrent/${local.infoHash}/file`">下载 .torrent</a>
<a v-if="local.sourceUrl" class="btn btn-sm btn-ghost" :href="local.sourceUrl" target="_blank" rel="noreferrer noopener">
打开来源页面
</a>
</div>
</div>
</div>
<SendPanel :torrent="local" />
<div v-if="previewImages.length" class="note">
文件清单里有 {{ previewImages.length }} 张图片可以点「预览」直接看:只会按需拉取这几张图所在的分片,不会下载整个种子。
</div>
<div class="panel">
<div class="panel-bd tight">
<FileTable
:files="files"
:total="total"
:resolving="resolving"
:resolve-error="local.resolveError"
@resolve="doResolve"
@preview="openPreview"
/>
</div>
</div>
<ImagePreview
v-if="previewIndex !== null"
:info-hash="local.infoHash"
:magnet="local.magnet"
:images="previewImages"
:index="previewIndex"
@update:index="previewIndex = $event"
@close="previewIndex = null"
/>
</div>
</template>