feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
const toasts = useToasts()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toasts">
|
||||
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.kind" @click="dismissToast(t.id)">
|
||||
<span class="mono small" :class="t.kind === 'err' ? 'err' : t.kind === 'ok' ? 'ok' : 'acc'">
|
||||
{{ t.kind === 'err' ? '✕' : t.kind === 'warn' ? '!' : t.kind === 'ok' ? '✓' : 'i' }}
|
||||
</span>
|
||||
<span>
|
||||
<strong style="font-weight: 600">{{ t.title }}</strong>
|
||||
<span v-if="t.detail" class="dim" style="display: block; margin-top: 2px; font-size: 12px">{{ t.detail }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import type { TorrentFileEntry } from '~/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
files: TorrentFileEntry[] | null
|
||||
total: number
|
||||
resolving?: boolean
|
||||
resolveError?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ resolve: []; preview: [file: TorrentFileEntry] }>()
|
||||
|
||||
const keyword = ref('')
|
||||
const sort = ref<'size' | 'name'>('size')
|
||||
|
||||
/** 图片且体积没超上限,才值得从 swarm 拉下来预览 */
|
||||
function previewable(file: TorrentFileEntry): boolean {
|
||||
return file.length > 0 && file.length <= MAX_IMAGE_PREVIEW_BYTES && isImagePath(file.path)
|
||||
}
|
||||
|
||||
const visible = computed(() => {
|
||||
let list = props.files || []
|
||||
if (keyword.value) {
|
||||
const kw = keyword.value.toLowerCase()
|
||||
list = list.filter((f) => f.path.toLowerCase().includes(kw))
|
||||
}
|
||||
const sorted = [...list]
|
||||
if (sort.value === 'size') sorted.sort((a, b) => b.length - a.length)
|
||||
else sorted.sort((a, b) => a.path.localeCompare(b.path))
|
||||
return sorted.slice(0, 500)
|
||||
})
|
||||
|
||||
const truncated = computed(() => (props.files?.length || 0) > 500)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="resolving" class="empty">
|
||||
<span class="spin" /> 正在通过 DHT / tracker 获取种子元数据,通常几秒到几十秒…
|
||||
</div>
|
||||
|
||||
<div v-else-if="!files" class="stack sm" style="padding: 18px">
|
||||
<div class="note warn">
|
||||
这个种子只有 infoHash,还没有文件清单。可以点击下面的按钮在线拉取元数据(需要本机能连上 DHT / tracker)。
|
||||
</div>
|
||||
<div v-if="resolveError" class="note err">{{ resolveError }}</div>
|
||||
<div>
|
||||
<button class="btn btn-primary" @click="emit('resolve')">在线解析文件清单</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="panel-hd" style="border-top: none">
|
||||
<span class="label">FILE LIST</span>
|
||||
<span class="mono small dim">{{ formatNumber(files.length) }} 个文件 · {{ formatBytes(total) }}</span>
|
||||
<span class="spacer" />
|
||||
<input v-model="keyword" class="input" style="width: 200px" placeholder="过滤文件名…" />
|
||||
<select v-model="sort" class="select" style="width: 120px">
|
||||
<option value="size">按大小</option>
|
||||
<option value="name">按名称</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="max-height: 520px; overflow: auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 58%">路径</th>
|
||||
<th class="right" style="width: 14%">大小</th>
|
||||
<th class="right" style="width: 10%">占比</th>
|
||||
<th style="width: 76px" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(f, i) in visible" :key="i">
|
||||
<td class="file-path">
|
||||
<span class="file-dir">{{ splitPath(f.path).dir }}</span><span class="file-name">{{ splitPath(f.path).name }}</span>
|
||||
</td>
|
||||
<td class="num right">{{ formatBytes(f.length) }}</td>
|
||||
<td class="num right mute">{{ filePercent(f.length, total) }}</td>
|
||||
<td class="right">
|
||||
<button
|
||||
v-if="previewable(f)"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:title="`从做种节点拉取这张图片用于预览:${f.path}`"
|
||||
@click="emit('preview', f)"
|
||||
>
|
||||
预览
|
||||
</button>
|
||||
<span v-else-if="isImagePath(f.path)" class="dim small" title="图片体积超出在线预览上限">过大</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="truncated" class="empty">结果较多,表格只展示前 500 条,可用过滤框缩小范围</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import type { TorrentFileEntry } from '~/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
infoHash: string
|
||||
magnet: string
|
||||
/** 全部可预览的图片,按文件清单原始顺序 */
|
||||
images: TorrentFileEntry[]
|
||||
index: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; 'update:index': [value: number] }>()
|
||||
|
||||
const status = ref<'loading' | 'ok' | 'err'>('loading')
|
||||
const errorText = ref('')
|
||||
const src = ref('')
|
||||
let objectUrl = ''
|
||||
|
||||
const current = computed<TorrentFileEntry | undefined>(() => props.images[props.index])
|
||||
const requestUrl = computed(
|
||||
() =>
|
||||
`/api/torrent/${props.infoHash}/preview?path=${encodeURIComponent(
|
||||
current.value?.path || ''
|
||||
)}&magnet=${encodeURIComponent(props.magnet)}`
|
||||
)
|
||||
|
||||
function revoke() {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
objectUrl = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
revoke()
|
||||
src.value = ''
|
||||
errorText.value = ''
|
||||
status.value = 'loading'
|
||||
try {
|
||||
const res = await fetch(requestUrl.value)
|
||||
if (!res.ok) {
|
||||
// 服务端用 statusMessage 带上可读原因:没有做种节点 / 图片过大 / 文件不存在
|
||||
const text = await res.text()
|
||||
let message = text
|
||||
try {
|
||||
const json = JSON.parse(text)
|
||||
message = json.statusMessage || json.message || text
|
||||
} catch {
|
||||
/* 不是 JSON 就用原文 */
|
||||
}
|
||||
throw new Error(message || `请求失败(HTTP ${res.status})`)
|
||||
}
|
||||
objectUrl = URL.createObjectURL(await res.blob())
|
||||
src.value = objectUrl
|
||||
status.value = 'ok'
|
||||
} catch (err) {
|
||||
errorText.value = errorMessage(err)
|
||||
status.value = 'err'
|
||||
}
|
||||
}
|
||||
|
||||
function step(delta: number) {
|
||||
const next = props.index + delta
|
||||
if (next < 0 || next >= props.images.length) return
|
||||
emit('update:index', next)
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
else if (e.key === 'ArrowLeft') step(-1)
|
||||
else if (e.key === 'ArrowRight') step(1)
|
||||
}
|
||||
|
||||
watch(() => [props.index, props.infoHash, props.magnet], load, { immediate: true })
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
revoke()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overlay" @click.self="emit('close')">
|
||||
<div class="lightbox">
|
||||
<div class="panel-hd">
|
||||
<span class="label">IMAGE</span>
|
||||
<span class="mono small truncate" style="max-width: 36ch" :title="current?.path">
|
||||
{{ splitPath(current?.path || '').name }}
|
||||
</span>
|
||||
<span class="spacer" />
|
||||
<span class="mono small dim">{{ index + 1 }} / {{ images.length }}</span>
|
||||
<span class="mono small dim">{{ formatBytes(current?.length) }}</span>
|
||||
<button class="btn btn-sm btn-ghost" @click="emit('close')">关闭</button>
|
||||
</div>
|
||||
|
||||
<div class="lightbox-bd">
|
||||
<div v-if="status === 'loading'" class="empty">
|
||||
<span class="spin" /> 正在从做种节点拉取这张图片,取决于对方的上传速度…
|
||||
</div>
|
||||
<div v-else-if="status === 'err'" class="stack sm" style="padding: 22px">
|
||||
<div class="note err">{{ errorText }}</div>
|
||||
<div class="row">
|
||||
<button class="btn btn-sm" @click="load">重试</button>
|
||||
<a class="btn btn-sm btn-ghost" :href="requestUrl" target="_blank" rel="noreferrer noopener">
|
||||
直接打开图片地址
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<img v-else :src="src" :alt="current?.path || 'preview'" class="lightbox-img" />
|
||||
</div>
|
||||
|
||||
<div class="lightbox-ft">
|
||||
<button class="btn btn-sm" :disabled="index <= 0" @click="step(-1)">← 上一张</button>
|
||||
<span class="mono small dim truncate" style="max-width: 44ch" :title="current?.path">{{ current?.path }}</span>
|
||||
<button class="btn btn-sm" :disabled="index >= images.length - 1" @click="step(1)">下一张 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClientsResponse, TorrentView } from '~/types/api'
|
||||
|
||||
const props = defineProps<{ torrent: TorrentView }>()
|
||||
|
||||
const clients = ref<ClientsResponse | null>(null)
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const selectedId = ref('')
|
||||
const dir = ref('')
|
||||
|
||||
const statuses = computed(() => clients.value?.statuses || [])
|
||||
const onlineStatuses = computed(() => statuses.value.filter((s) => s.online))
|
||||
const current = computed(() => statuses.value.find((s) => s.id === selectedId.value) || null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
clients.value = await $fetch<ClientsResponse>('/api/clients')
|
||||
const firstOnline = clients.value.statuses.find((s) => s.online)
|
||||
if (!selectedId.value) selectedId.value = (firstOnline || clients.value.statuses[0])?.id || ''
|
||||
syncDir()
|
||||
} catch (err) {
|
||||
pushToast('err', '读取下载器配置失败', errorMessage(err))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function syncDir() {
|
||||
const cfg = clients.value?.configs.find((c) => c.id === selectedId.value)
|
||||
dir.value = cfg?.downloadDir || ''
|
||||
}
|
||||
|
||||
watch(selectedId, syncDir)
|
||||
|
||||
async function send() {
|
||||
if (!selectedId.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
const res = await $fetch<{ ok: boolean; message: string }>('/api/send', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
clientId: selectedId.value,
|
||||
infoHash: props.torrent.infoHash,
|
||||
magnet: props.torrent.magnet || undefined,
|
||||
name: props.torrent.name,
|
||||
downloadDir: dir.value || undefined
|
||||
}
|
||||
})
|
||||
pushToast('ok', '投递成功', res.message)
|
||||
refreshHealth()
|
||||
} catch (err) {
|
||||
pushToast('err', '投递失败', errorMessage(err))
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="panel-hd">
|
||||
<span class="label">SEND TO LOCAL CLIENT</span>
|
||||
<span class="mono small" :class="onlineStatuses.length ? 'ok' : 'err'">
|
||||
{{ loading ? '探测中…' : `${onlineStatuses.length}/${statuses.length} 在线` }}
|
||||
</span>
|
||||
<span class="spacer" />
|
||||
<button class="btn btn-sm btn-ghost" :disabled="loading" @click="load">重新探测</button>
|
||||
<NuxtLink to="/settings" class="btn btn-sm btn-ghost">配置</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="panel-bd stack sm">
|
||||
<div v-if="!statuses.length" class="note warn">
|
||||
还没有配置任何下载器。去「设置」里填 qBittorrent / Transmission / aria2 的地址和账号密码。
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="row wrap">
|
||||
<div
|
||||
v-for="s in statuses"
|
||||
:key="s.id"
|
||||
class="pill"
|
||||
:class="s.online ? 'on' : 'off'"
|
||||
:title="s.error || s.endpoint"
|
||||
>
|
||||
<i class="dot" />
|
||||
{{ s.name }}
|
||||
<span class="mute">{{ s.version ? `v${s.version}` : s.online ? '' : '离线' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<label class="field">
|
||||
<span class="label">目标下载器</span>
|
||||
<select v-model="selectedId" class="select">
|
||||
<option v-for="s in statuses" :key="s.id" :value="s.id">
|
||||
{{ s.name }}({{ s.online ? '在线' : '离线' }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">下载目录(留空用客户端默认)</span>
|
||||
<input v-model="dir" class="input" placeholder="D:\\Downloads" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="current && !current.online" class="note err">{{ current.error || '该下载器当前不可用' }}</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="btn btn-primary" :disabled="sending || !selectedId" @click="send">
|
||||
<span v-if="sending" class="spin" />
|
||||
{{ sending ? '投递中…' : '投递到本机下载器' }}
|
||||
</button>
|
||||
<span class="mute small">种子交给本机客户端下载,服务器不中转文件内容</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user