diff --git a/app/plugins/pwa.client.ts b/app/plugins/pwa.client.ts
new file mode 100644
index 0000000..8a2b5cb
--- /dev/null
+++ b/app/plugins/pwa.client.ts
@@ -0,0 +1,5 @@
+export default defineNuxtPlugin(() => {
+ if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
+ navigator.serviceWorker.register('/sw.js').catch(() => {})
+ }
+})
diff --git a/nuxt.config.ts b/nuxt.config.ts
index fb766a1..cad4dc1 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -8,7 +8,15 @@ export default defineNuxtConfig({
title: 'BT 种子预览台',
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
- { name: 'description', content: '搜索 / 解析 / 预览 BT 种子内容,并投递到本机下载工具' }
+ { name: 'description', content: '搜索 / 解析 / 预览 BT 种子内容,并投递到本机下载工具' },
+ { name: 'theme-color', content: '#0f0f11' },
+ { name: 'apple-mobile-web-app-capable', content: 'yes' },
+ { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
+ { name: 'apple-mobile-web-app-title', content: 'BT 预览台' }
+ ],
+ link: [
+ { rel: 'manifest', href: '/manifest.json' },
+ { rel: 'apple-touch-icon', href: '/icon.svg', sizes: 'any' }
]
}
},
diff --git a/public/icon.svg b/public/icon.svg
new file mode 100644
index 0000000..8f030aa
--- /dev/null
+++ b/public/icon.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..87b1de7
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,19 @@
+{
+ "name": "BT 种子预览台",
+ "short_name": "BT 预览台",
+ "description": "搜索 / 解析 / 预览 BT 种子内容,并投递到本机下载工具",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait-primary",
+ "background_color": "#0f0f11",
+ "theme_color": "#0f0f11",
+ "icons": [
+ {
+ "src": "/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..9e53637
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,33 @@
+const CACHE_NAME = 'bt-preview-v1'
+const SHELL = ['/', '/gate', '/settings', '/manifest.json', '/icon.svg']
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting())
+ )
+})
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim())
+})
+
+self.addEventListener('fetch', (event) => {
+ const { request } = event
+ const url = new URL(request.url)
+
+ // API、图片预览、下载等动态请求走网络,不做缓存
+ if (url.pathname.startsWith('/api/')) return
+
+ event.respondWith(
+ caches.match(request).then((cached) => {
+ if (cached) return cached
+ return fetch(request).then((res) => {
+ if (request.method === 'GET' && res.status === 200 && res.type === 'basic') {
+ const clone = res.clone()
+ caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)).catch(() => {})
+ }
+ return res
+ })
+ })
+ )
+})
diff --git a/server/api/torrent/[infohash]/preview.get.ts b/server/api/torrent/[infohash]/preview.get.ts
index a22d449..15f77b6 100644
--- a/server/api/torrent/[infohash]/preview.get.ts
+++ b/server/api/torrent/[infohash]/preview.get.ts
@@ -47,37 +47,45 @@ export default defineEventHandler(async (event) => {
}
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([
- target.arrayBuffer(),
- new Promise((_, reject) => {
- setTimeout(
- () => reject(new Error('下载图片超时:暂时没有可用的做种节点,稍后再试')),
- DOWNLOAD_TIMEOUT_MS
- )
- })
- ])
- })
+ try {
+ 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([
+ target.arrayBuffer(),
+ new Promise((_, 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)
+ 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)
+ } catch (err) {
+ // 把 withSwarm 抛出的原始错误(如 webtorrent 无法加载)转成可读的 HTTP 错误,
+ // 避免前端看到 Nitro 的通用 Server Error 页面。
+ if (err && typeof err === 'object' && 'statusCode' in err) throw err
+ const message = err instanceof Error ? err.message : '从做种节点获取图片失败'
+ throw createError({ statusCode: 503, statusMessage: message })
+ }
})