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 @@ + + + BT + 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 }) + } })