diff --git a/bun.lockb b/bun.lockb index 57090da..60ea595 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index b00c26f..8b769d3 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@microsoft/signalr": "^8.0.7", "@microsoft/signalr-protocol-msgpack": "^8.0.7", "@mixer/postmessage-rpc": "^1.1.4", + "@oneidentity/zstd-js": "^1.0.3", "@tauri-apps/api": "^2.4.0", "@tauri-apps/plugin-autostart": "^2.3.0", "@tauri-apps/plugin-http": "^2.4.2", diff --git a/src/Utils.ts b/src/Utils.ts index c7875fb..2f43172 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -1,4 +1,4 @@ -import { useStorage } from '@vueuse/core' +import { useStorage } from '@vueuse/core'; import { ConfigProviderProps, NButton, @@ -10,114 +10,114 @@ import { dateZhCN, useOsTheme, zhCN -} from 'naive-ui' -import { SongFrom, SongsInfo, ThemeType } from './api/api-models' -import { computed } from 'vue' -import { VTSURU_API_URL } from './data/constants' -import { DiscreteApiType } from 'naive-ui/es/discrete/src/interface' +} from 'naive-ui'; +import { SongFrom, SongsInfo, ThemeType } from './api/api-models'; +import { computed } from 'vue'; +import { VTSURU_API_URL } from './data/constants'; +import { DiscreteApiType } from 'naive-ui/es/discrete/src/interface'; import { SquareArrowForward24Filled } from '@vicons/fluent'; -import FiveSingIcon from '@/svgs/fivesing.svg' -import NeteaseIcon from '@/svgs/netease.svg' +import FiveSingIcon from '@/svgs/fivesing.svg'; +import NeteaseIcon from '@/svgs/netease.svg'; -const { message } = createDiscreteApi(['message']) +const { message } = createDiscreteApi(['message']); -const osThemeRef = useOsTheme() //获取当前系统主题 -const themeType = useStorage('Settings.Theme', ThemeType.Auto) +const osThemeRef = useOsTheme(); //获取当前系统主题 +const themeType = useStorage('Settings.Theme', ThemeType.Auto); export const theme = computed(() => { if (themeType.value == ThemeType.Auto) { - var osThemeRef = useOsTheme() //获取当前系统主题 - return osThemeRef.value === 'dark' ? darkTheme : null + var osThemeRef = useOsTheme(); //获取当前系统主题 + return osThemeRef.value === 'dark' ? darkTheme : null; } else { - return themeType.value == ThemeType.Dark ? darkTheme : null + return themeType.value == ThemeType.Dark ? darkTheme : null; } -}) +}); export const configProviderPropsRef = computed(() => ({ theme: theme.value, locale: zhCN, dateLocale: dateZhCN, -})) +})); export function createNaiveUIApi(types: DiscreteApiType[]) { return createDiscreteApi(types, { configProviderProps: configProviderPropsRef - }) + }); } export function NavigateToNewTab(url: string) { - window.open(url, '_blank') + window.open(url, '_blank'); } export const isDarkMode = computed(() => { - if (themeType.value == ThemeType.Auto) return osThemeRef.value === 'dark' - else return themeType.value == ThemeType.Dark -}) + if (themeType.value == ThemeType.Auto) return osThemeRef.value === 'dark'; + else return themeType.value == ThemeType.Dark; +}); export function copyToClipboard(text: string) { if (navigator.clipboard) { - navigator.clipboard.writeText(text) - message.success('已复制到剪切板') + navigator.clipboard.writeText(text); + message.success('已复制到剪切板'); } else { - message.warning('当前环境不支持自动复制, 请手动选择并复制') + message.warning('当前环境不支持自动复制, 请手动选择并复制'); } } export function objectsToCSV(arr: any[]) { - const array = [Object.keys(arr[0])].concat(arr) + const array = [Object.keys(arr[0])].concat(arr); return array .map((row) => { return Object.values(row) .map((value) => { - return typeof value === 'string' ? JSON.stringify(value) : value + return typeof value === 'string' ? JSON.stringify(value) : value; }) - .toString() + .toString(); }) - .join('\n') + .join('\n'); } export function GetGuardColor(level: number | null | undefined): string { if (level) { switch (level) { case 1: { - return 'rgb(122, 4, 35)' + return 'rgb(122, 4, 35)'; } case 2: { - return 'rgb(157, 155, 255)' + return 'rgb(157, 155, 255)'; } case 3: { - return 'rgb(104, 136, 241)' + return 'rgb(104, 136, 241)'; } } } - return '' + return ''; } export function downloadImage(imageSrc: string, filename: string) { - const image = new Image() - image.crossOrigin = 'Anonymous' // This might be needed depending on the image's server + const image = new Image(); + image.crossOrigin = 'Anonymous'; // This might be needed depending on the image's server image.onload = () => { - const canvas = document.createElement('canvas') - canvas.width = image.width - canvas.height = image.height - const ctx = canvas.getContext('2d') - ctx!.drawImage(image, 0, 0) + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext('2d'); + ctx!.drawImage(image, 0, 0); canvas.toBlob((blob) => { if (blob) { - const link = document.createElement('a') - link.href = URL.createObjectURL(blob) - link.download = filename - document.body.appendChild(link) - link.click() - document.body.removeChild(link) + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); } - }) // Omitted the 'image/jpeg' to use the original image format - } - image.src = imageSrc + }); // Omitted the 'image/jpeg' to use the original image format + }; + image.src = imageSrc; } export function getBase64( file: File | undefined | null ): Promise { - if (!file) return new Promise((resolve) => resolve(undefined)) + if (!file) return new Promise((resolve) => resolve(undefined)); return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.readAsDataURL(file) + const reader = new FileReader(); + reader.readAsDataURL(file); reader.onload = () => - resolve(reader.result?.toString().split(',')[1] || undefined) - reader.onerror = (error) => reject(error) - }) + resolve(reader.result?.toString().split(',')[1] || undefined); + reader.onerror = (error) => reject(error); + }); } export async function getImageUploadModel( files: UploadFileInfo[] | undefined | null, @@ -126,83 +126,83 @@ export async function getImageUploadModel( const result = { existImages: [], newImagesBase64: [] - } as { existImages: string[]; newImagesBase64: string[] } - if (!files) return result + } as { existImages: string[]; newImagesBase64: string[]; }; + if (!files) return result; for (let i = 0; i < files.length; i++) { - const file = files[i] + const file = files[i]; if ((file.file?.size ?? 0) > maxSize) { - message.error('文件大小不能超过 ' + maxSize / 1024 / 1024 + 'MB') - return result + message.error('文件大小不能超过 ' + maxSize / 1024 / 1024 + 'MB'); + return result; } if (!file.file) { - result.existImages.push(file.id) //用id绝对路径当的文件名 + result.existImages.push(file.id); //用id绝对路径当的文件名 } else { - const base64 = await getBase64(file.file) + const base64 = await getBase64(file.file); if (base64) { - result.newImagesBase64.push(base64) + result.newImagesBase64.push(base64); } } } - return result + return result; } export function getUserAvatarUrl(userId: number | undefined | null) { - if (!userId) return '' - return VTSURU_API_URL + 'user-face/' + userId + if (!userId) return ''; + return VTSURU_API_URL + 'user-face/' + userId; } export function getOUIdAvatarUrl(ouid: string) { - return VTSURU_API_URL + 'face/' + ouid + return VTSURU_API_URL + 'face/' + ouid; } export class GuidUtils { // 将数字转换为GUID public static numToGuid(value: number): string { - const buffer = new ArrayBuffer(16) - const view = new DataView(buffer) - view.setBigUint64(8, BigInt(value)) // 将数字写入后8个字节 - return GuidUtils.bufferToGuid(buffer) + const buffer = new ArrayBuffer(16); + const view = new DataView(buffer); + view.setBigUint64(8, BigInt(value)); // 将数字写入后8个字节 + return GuidUtils.bufferToGuid(buffer); } // 检查GUID是否由数字生成 public static isGuidFromUserId(guid: string): boolean { - const buffer = GuidUtils.guidToBuffer(guid) - const view = new DataView(buffer) + const buffer = GuidUtils.guidToBuffer(guid); + const view = new DataView(buffer); for (let i = 0; i < 8; i++) { - if (view.getUint8(i) !== 0) return false // 检查前8个字节是否为0 + if (view.getUint8(i) !== 0) return false; // 检查前8个字节是否为0 } - return true + return true; } // 将GUID转换为数字 public static guidToLong(guid: string): number { - const buffer = GuidUtils.guidToBuffer(guid) - const view = new DataView(buffer) - return Number(view.getBigUint64(8)) + const buffer = GuidUtils.guidToBuffer(guid); + const view = new DataView(buffer); + return Number(view.getBigUint64(8)); } // 辅助方法:将ArrayBuffer转换为GUID字符串 private static bufferToGuid(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer) + const bytes = new Uint8Array(buffer); const guid = bytes.reduce((str, byte, idx) => { - const pair = byte.toString(16).padStart(2, '0') + const pair = byte.toString(16).padStart(2, '0'); return ( str + pair + (idx === 3 || idx === 5 || idx === 7 || idx === 9 ? '-' : '') - ) - }, '') - return guid + ); + }, ''); + return guid; } // 辅助方法:将GUID字符串转换为ArrayBuffer private static guidToBuffer(guid: string): ArrayBuffer { - const hex = guid.replace(/-/g, '') - if (hex.length !== 32) throw new Error('Invalid GUID format.') - const buffer = new ArrayBuffer(16) - const view = new DataView(buffer) + const hex = guid.replace(/-/g, ''); + if (hex.length !== 32) throw new Error('Invalid GUID format.'); + const buffer = new ArrayBuffer(16); + const view = new DataView(buffer); for (let i = 0; i < 16; i++) { - view.setUint8(i, parseInt(hex.substr(i * 2, 2), 16)) + view.setUint8(i, parseInt(hex.substr(i * 2, 2), 16)); } - return buffer + return buffer; } } export function GetPlayButton(song: SongsInfo) { @@ -218,7 +218,7 @@ export function GetPlayButton(song: SongsInfo) { color: '#00BBB3', ghost: true, onClick: () => { - window.open(`http://5sing.kugou.com/bz/${song.id}.html`) + window.open(`http://5sing.kugou.com/bz/${song.id}.html`); }, }, { @@ -227,7 +227,7 @@ export function GetPlayButton(song: SongsInfo) { ), ), default: () => '在5sing打开', - }) + }); } case SongFrom.Netease: return h(NTooltip, null, { @@ -239,7 +239,7 @@ export function GetPlayButton(song: SongsInfo) { color: '#C20C0C', ghost: true, onClick: () => { - window.open(`https://music.163.com/#/song?id=${song.id}`) + window.open(`https://music.163.com/#/song?id=${song.id}`); }, }, { @@ -247,7 +247,7 @@ export function GetPlayButton(song: SongsInfo) { }, ), default: () => '在网易云打开', - }) + }); case SongFrom.Custom: return song.url ? h(NTooltip, null, { @@ -259,7 +259,7 @@ export function GetPlayButton(song: SongsInfo) { color: '#6b95bd', ghost: true, onClick: () => { - window.open(song.url) + window.open(song.url); }, }, { @@ -268,6 +268,33 @@ export function GetPlayButton(song: SongsInfo) { ), default: () => '打开链接', }) - : null + : null; + } +} +export function getBrowserName() { + var userAgent = navigator.userAgent; + if (userAgent.indexOf("Opera") > -1 || userAgent.indexOf("OPR") > -1) { + return 'Opera'; + } + else if (userAgent.indexOf("compatible") > -1 && userAgent.indexOf("MSIE") > -1) { + return 'IE'; + } + else if (userAgent.indexOf("Edge") > -1) { + return 'Edge'; + } + else if (userAgent.indexOf("Firefox") > -1) { + return 'Firefox'; + } + else if (userAgent.indexOf("Safari") > -1 && userAgent.indexOf("Chrome") == -1) { + return 'Safari'; + } + else if (userAgent.indexOf("Chrome") > -1 && userAgent.indexOf("Safari") > -1) { + return 'Chrome'; + } + else if (!!window.ActiveXObject || "ActiveXObject" in window) { + return 'IE>=11'; + } + else { + return 'Unkonwn'; } } \ No newline at end of file diff --git a/src/api/api-models.ts b/src/api/api-models.ts index 8cee2e4..65a79d8 100644 --- a/src/api/api-models.ts +++ b/src/api/api-models.ts @@ -54,6 +54,7 @@ export interface EventFetcherStateModel { export enum EventFetcherType { Application, OBS, + Tauri, Server } export interface AccountInfo extends UserInfo { diff --git a/src/client/ClientFetcher.vue b/src/client/ClientFetcher.vue index 338b49a..a589d73 100644 --- a/src/client/ClientFetcher.vue +++ b/src/client/ClientFetcher.vue @@ -357,8 +357,8 @@ const minutes = duration.minutes || 0; const seconds = duration.seconds || 0; return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } - async function onSwitchDanmakuClientMode(type: 'openlive' | 'direct') { - if (webfetcher.webfetcherType === type) { + async function onSwitchDanmakuClientMode(type: 'openlive' | 'direct', force: boolean = false) { + if (webfetcher.webfetcherType === type && !force) { message.info('当前已是该模式'); return; } const noticeRef = window.$notification.info({ @@ -494,8 +494,7 @@ embedded style="width: 100%; max-width: 800px;" > - - 模式: + + + + + @@ -789,6 +809,7 @@ bordered :columns="2" size="small" + style="overflow-x: auto;" > {{ formattedStartedAt }} @@ -800,6 +821,7 @@ {{ signalRStateText }} - - {{ webfetcher.signalRClient?.connectionId ?? 'N/A' }} + + {{ webfetcher.signalRId ?? 'N/A' }} @@ -816,6 +838,7 @@ {{ danmakuClientStateText }} - + {{ webfetcher.danmakuServerUrl ?? 'N/A' }} @@ -968,10 +991,6 @@ {{ webfetcher.sessionEventCount?.toLocaleString() ?? 0 }} - - 成功: {{ webfetcher.successfulUploads ?? 0 }} / 失败: - {{ webfetcher.failedUploads ?? 0 }} - {{ ((webfetcher.bytesSentSession ?? 0) / 1024).toFixed(2) }} KB diff --git a/src/client/ClientLayout.vue b/src/client/ClientLayout.vue index 4ed3a20..3df7e95 100644 --- a/src/client/ClientLayout.vue +++ b/src/client/ClientLayout.vue @@ -17,6 +17,7 @@ import WindowBar from './WindowBar.vue'; import { initAll, OnClientUnmounted } from './data/initialize'; import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent'; +import { isTauri } from '@/data/constants'; // --- 响应式状态 --- @@ -68,7 +69,7 @@ import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent'; window.$message.success('登陆成功'); ACCOUNT.value = result; // 更新全局账户信息 // isLoadingAccount.value = false; // 状态在 finally 中统一处理 - initAll(); // 初始化 WebFetcher + //initAll(false); // 初始化 WebFetcher } } } catch (error) { @@ -116,160 +117,169 @@ import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent'; diff --git a/src/store/useWebFetcher.ts b/src/store/useWebFetcher.ts index d2b6792..4f7e103 100644 --- a/src/store/useWebFetcher.ts +++ b/src/store/useWebFetcher.ts @@ -1,19 +1,21 @@ -import { defineStore } from 'pinia'; -import { ref, computed, shallowRef } from 'vue'; // shallowRef 用于非深度响应对象 -import { useLocalStorage } from '@vueuse/core'; -import { useRoute } from 'vue-router'; -import { compress } from 'brotli-compress'; -import { format } from 'date-fns'; +import { cookie, useAccount } from '@/api/account'; +import { getEventType, recordEvent, streamingInfo } from '@/client/data/info'; +import { BASE_HUB_URL, isDev, isTauri } from '@/data/constants'; +import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient'; +import DirectClient, { DirectClientAuthInfo } from '@/data/DanmakuClients/DirectClient'; +import OpenLiveClient from '@/data/DanmakuClients/OpenLiveClient'; import * as signalR from '@microsoft/signalr'; import * as msgpack from '@microsoft/signalr-protocol-msgpack'; -import { cookie, useAccount } from '@/api/account'; // 假设账户信息路径 -import { BASE_HUB_URL, isDev } from '@/data/constants'; // 假设常量路径 -import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient'; // 假设弹幕客户端基类路径 -import DirectClient, { DirectClientAuthInfo } from '@/data/DanmakuClients/DirectClient'; // 假设直连客户端路径 -import OpenLiveClient from '@/data/DanmakuClients/OpenLiveClient'; // 假设开放平台客户端路径 -import { error as logError, info as logInfo } from '@tauri-apps/plugin-log'; // 使用日志插件 -import { getEventType, recordEvent, streamingInfo } from '@/client/data/info'; +import { defineStore } from 'pinia'; +import { computed, ref, shallowRef } from 'vue'; // shallowRef 用于非深度响应对象 +import { useRoute } from 'vue-router'; import { useWebRTC } from './useRTC'; +import { QueryBiliAPI } from '@/client/data/utils'; +import { platform, type, version } from '@tauri-apps/plugin-os'; +import { ZstdCodec, ZstdInit } from '@oneidentity/zstd-js/wasm'; + +import { encode } from "@msgpack/msgpack"; +import { getVersion } from '@tauri-apps/api/app'; export const useWebFetcher = defineStore('WebFetcher', () => { const route = useRoute(); @@ -24,11 +26,14 @@ export const useWebFetcher = defineStore('WebFetcher', () => { const state = ref<'disconnected' | 'connecting' | 'connected'>('disconnected'); // SignalR 连接状态 const startedAt = ref(); // 本次启动时间 const signalRClient = shallowRef(); // SignalR 客户端实例 (浅响应) + const signalRId = ref(); // SignalR 连接 ID const client = shallowRef(); // 弹幕客户端实例 (浅响应) let timer: any; // 事件发送定时器 let disconnectedByServer = false; let isFromClient = false; // 是否由Tauri客户端启动 + + // --- 新增: 详细状态与信息 --- /** 弹幕客户端内部状态 */ const danmakuClientState = ref<'stopped' | 'connecting' | 'connected'>('stopped'); // 更详细的弹幕客户端状态 @@ -52,6 +57,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => { const failedUploads = ref(0); /** 本次会话发送的总字节数 (压缩后) */ const bytesSentSession = ref(0); + let zstd: ZstdCodec | undefined = undefined; // Zstd 编码器实例 (如果需要压缩) const prefix = computed(() => isFromClient ? '[web-fetcher-iframe] ' : '[web-fetcher] '); @@ -64,9 +70,15 @@ export const useWebFetcher = defineStore('WebFetcher', () => { _isFromClient: boolean = false ): Promise<{ success: boolean; message: string; }> { if (state.value === 'connected' || state.value === 'connecting') { - logInfo(prefix.value + '已经启动,无需重复启动'); + console.log(prefix.value + '已经启动,无需重复启动'); return { success: true, message: '已启动' }; } + try { + zstd ??= await ZstdInit(); + + } catch (error) { + console.error(prefix.value + '当前浏览器不支持zstd压缩, 回退到原始数据传输'); + } webfetcherType.value = type; // 设置弹幕客户端类型 // 重置会话统计数据 resetSessionStats(); @@ -76,9 +88,9 @@ export const useWebFetcher = defineStore('WebFetcher', () => { // 使用 navigator.locks 确保同一时间只有一个 Start 操作执行 const result = await navigator.locks.request('webFetcherStartLock', async () => { - logInfo(prefix.value + '开始启动...'); + console.log(prefix.value + '开始启动...'); while (!(await connectSignalR())) { - logInfo(prefix.value + '连接 SignalR 失败, 5秒后重试'); + console.log(prefix.value + '连接 SignalR 失败, 5秒后重试'); await new Promise((resolve) => setTimeout(resolve, 5000)); // 如果用户手动停止,则退出重试循环 if (state.value === 'disconnected') return { success: false, message: '用户手动停止' }; @@ -86,7 +98,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => { let danmakuResult = await connectDanmakuClient(type, directAuthInfo); while (!danmakuResult?.success) { - logInfo(prefix.value + '弹幕客户端启动失败, 5秒后重试'); + console.log(prefix.value + '弹幕客户端启动失败, 5秒后重试'); await new Promise((resolve) => setTimeout(resolve, 5000)); // 如果用户手动停止,则退出重试循环 if (state.value === 'disconnected') return { success: false, message: '用户手动停止' }; @@ -96,7 +108,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => { // 只有在两个连接都成功后才设置为 connected state.value = 'connected'; disconnectedByServer = false; - logInfo(prefix.value + '启动成功'); + console.log(prefix.value + '启动成功'); return { success: true, message: '启动成功' }; }); @@ -115,7 +127,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => { function Stop() { if (state.value === 'disconnected') return; - logInfo(prefix.value + '正在停止...'); + console.log(prefix.value + '正在停止...'); state.value = 'disconnected'; // 立即设置状态,防止重连逻辑触发 // 清理定时器 @@ -137,7 +149,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => { events.length = 0; // 清空事件队列 // resetSessionStats(); // 会话统计在下次 Start 时重置 - logInfo(prefix.value + '已停止'); + console.log(prefix.value + '已停止'); } /** 重置会话统计数据 */ @@ -157,11 +169,11 @@ export const useWebFetcher = defineStore('WebFetcher', () => { directConnectInfo?: DirectClientAuthInfo ) { if (client.value?.state === 'connected' || client.value?.state === 'connecting') { - logInfo(prefix.value + '弹幕客户端已连接或正在连接'); + console.log(prefix.value + '弹幕客户端已连接或正在连接'); return { success: true, message: '弹幕客户端已启动' }; } - logInfo(prefix.value + '正在连接弹幕客户端...'); + console.log(prefix.value + '正在连接弹幕客户端...'); danmakuClientState.value = 'connecting'; // 如果实例存在但已停止,先清理 @@ -176,7 +188,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => { } else { if (!directConnectInfo) { danmakuClientState.value = 'stopped'; - logError(prefix.value + '未提供直连弹幕客户端认证信息'); + console.error(prefix.value + '未提供直连弹幕客户端认证信息'); return { success: false, message: '未提供弹幕客户端认证信息' }; } client.value = new DirectClient(directConnectInfo); @@ -193,13 +205,13 @@ export const useWebFetcher = defineStore('WebFetcher', () => { const result = await client.value?.Start(); if (result?.success) { - logInfo(prefix.value + '弹幕客户端连接成功, 开始监听弹幕'); + console.log(prefix.value + '弹幕客户端连接成功, 开始监听弹幕'); danmakuClientState.value = 'connected'; // 明确设置状态 danmakuServerUrl.value = client.value.serverUrl; // 获取服务器地址 // 启动事件发送定时器 (如果之前没有启动) timer ??= setInterval(sendEvents, 1500); // 每 1.5 秒尝试发送一次事件 } else { - logError(prefix.value + '弹幕客户端启动失败: ' + result?.message); + console.error(prefix.value + '弹幕客户端启动失败: ' + result?.message); danmakuClientState.value = 'stopped'; danmakuServerUrl.value = undefined; client.value = undefined; // 启动失败,清理实例,下次会重建 @@ -212,11 +224,11 @@ export const useWebFetcher = defineStore('WebFetcher', () => { */ async function connectSignalR() { if (signalRClient.value && signalRClient.value.state !== signalR.HubConnectionState.Disconnected) { - logInfo(prefix.value + "SignalR 已连接或正在连接"); + console.log(prefix.value + "SignalR 已连接或正在连接"); return true; } - logInfo(prefix.value + '正在连接到 vtsuru 服务器...'); + console.log(prefix.value + '正在连接到 vtsuru 服务器...'); const connection = new signalR.HubConnectionBuilder() .withUrl(BASE_HUB_URL + 'web-fetcher?token=' + (route.query.token ?? account.value.token), { // 使用 account.token headers: { Authorization: `Bearer ${cookie.value?.cookie}` }, @@ -229,71 +241,98 @@ export const useWebFetcher = defineStore('WebFetcher', () => { // --- SignalR 事件监听 --- connection.onreconnecting(error => { - logInfo(prefix.value + `与服务器断开,正在尝试重连... ${error?.message || ''}`); + console.log(prefix.value + `与服务器断开,正在尝试重连... ${error?.message || ''}`); state.value = 'connecting'; // 更新状态为连接中 signalRConnectionId.value = undefined; // 连接断开,ID失效 }); - connection.onreconnected(connectionId => { - logInfo(prefix.value + `与服务器重新连接成功! ConnectionId: ${connectionId}`); + connection.onreconnected(async connectionId => { + console.log(prefix.value + `与服务器重新连接成功! ConnectionId: ${connectionId}`); signalRConnectionId.value = connectionId ?? undefined; state.value = 'connected'; // 更新状态为已连接 - // 重连成功后可能需要重新发送标识 - if (isFromClient) { - connection.send('SetAsVTsuruClient').catch(err => logError(prefix.value + "Send SetAsVTsuruClient failed: " + err)); - } - connection.send('Reconnected').catch(err => logError(prefix.value + "Send Reconnected failed: " + err)); + signalRId.value = connectionId ?? await sendSelfInfo(connection); // 更新连接ID + connection.send('Reconnected').catch(err => console.error(prefix.value + "Send Reconnected failed: " + err)); }); connection.onclose(async (error) => { // 只有在不是由 Stop() 或服务器明确要求断开时才记录错误并尝试独立重连(虽然 withAutomaticReconnect 应该处理) if (state.value !== 'disconnected' && !disconnectedByServer) { - logError(prefix.value + `与服务器连接关闭: ${error?.message || '未知原因'}. 自动重连将处理.`); + console.error(prefix.value + `与服务器连接关闭: ${error?.message || '未知原因'}. 自动重连将处理.`); state.value = 'connecting'; // 标记为连接中,等待自动重连 signalRConnectionId.value = undefined; // withAutomaticReconnect 会处理重连,这里不需要手动调用 reconnect } else if (disconnectedByServer) { - logInfo(prefix.value + `连接已被服务器关闭.`); + console.log(prefix.value + `连接已被服务器关闭.`); Stop(); // 服务器要求断开,则彻底停止 } else { - logInfo(prefix.value + `连接已手动关闭.`); + console.log(prefix.value + `连接已手动关闭.`); } }); connection.on('Disconnect', (reason: unknown) => { - logInfo(prefix.value + '被服务器断开连接: ' + reason); + console.log(prefix.value + '被服务器断开连接: ' + reason); disconnectedByServer = true; // 标记是服务器主动断开 Stop(); // 服务器要求断开,调用 Stop 清理所有资源 }); + connection.on('Request', async (url: string, method: string, body: string, useCookie: boolean) => onRequest(url, method, body, useCookie)); // --- 尝试启动连接 --- try { await connection.start(); - logInfo(prefix.value + '已连接到 vtsuru 服务器, ConnectionId: ' + connection.connectionId); console.log(prefix.value + '已连接到 vtsuru 服务器, ConnectionId: ' + connection.connectionId); // 调试输出连接状态 signalRConnectionId.value = connection.connectionId ?? undefined; // 保存连接ID + signalRId.value = await sendSelfInfo(connection); // 发送客户端信息 await connection.send('Finished'); // 通知服务器已准备好 - if (isFromClient) { - await connection.send('SetAsVTsuruClient'); // 如果是客户端,发送标识 - } signalRClient.value = connection; // 保存实例 // state.value = 'connected'; // 状态将在 Start 函数末尾统一设置 return true; } catch (e) { - logError(prefix.value + '无法连接到 vtsuru 服务器: ' + e); + console.error(prefix.value + '无法连接到 vtsuru 服务器: ' + e); signalRConnectionId.value = undefined; signalRClient.value = undefined; // state.value = 'disconnected'; // 保持 connecting 或由 Start 控制 return false; } } + async function sendSelfInfo(client: signalR.HubConnection) { + return client.invoke('SetSelfInfo', + isTauri ? `tauri ${platform()} ${version()}` : navigator.userAgent, + isTauri ? 'tauri' : 'web', + isTauri ? await getVersion() : '1.0.0', + webfetcherType.value === 'direct'); + } + type ResponseFetchRequestData = { + Message: string; + Success: boolean; + Data: string; + }; + async function onRequest(url: string, method: string, body: string, useCookie: boolean) { + if (!isTauri) { + console.error(prefix.value + '非Tauri环境下无法处理请求: ' + url); + return { + Message: '非Tauri环境', + Success: false, + Data: '' + }; + } + const result = await QueryBiliAPI(url, method, body, useCookie); + console.log(`${prefix.value}请求 (${method})${url}: `, result.statusText); + if (result.ok) { + const data = await result.text(); + return { + Message: '请求成功', + Success: true, + Data: data + } as ResponseFetchRequestData; + } + } // async function reconnect() { // withAutomaticReconnect 存在时,此函数通常不需要手动调用 // if (disconnectedByServer || state.value === 'disconnected') return; - // logInfo(prefix.value + '尝试手动重连...'); + // console.log(prefix.value + '尝试手动重连...'); // try { // await signalRClient.value?.start(); - // logInfo(prefix.value + '手动重连成功'); + // console.log(prefix.value + '手动重连成功'); // signalRConnectionId.value = signalRClient.value?.connectionId ?? null; // state.value = 'connected'; // if (isFromClient) { @@ -301,7 +340,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => { // } // await signalRClient.value?.send('Reconnected'); // } catch (err) { - // logError(prefix.value + '手动重连失败: ' + err); + // console.error(prefix.value + '手动重连失败: ' + err); // setTimeout(reconnect, 10000); // 失败后10秒再次尝试 // } // } @@ -351,20 +390,34 @@ export const useWebFetcher = defineStore('WebFetcher', () => { try { - const result = await signalRClient.value.invoke<{ Success: boolean; Message: string; }>( - 'UploadEvents', batch, webfetcherType.value === 'direct'? true : false - ); + let result: { Success: boolean; Message: string; } = { Success: false, Message: '' }; + let length = 0; + let eventCharLength = batch.map(event => event.length).reduce((a, b) => a + b, 0); // 计算字符长度 + if (zstd && eventCharLength > 100) { + const data = zstd.ZstdSimple.compress(encode(batch), 11); + length = data.length; + result = await signalRClient.value.invoke<{ Success: boolean; Message: string; }>( + 'UploadEventsCompressedV2', data + ); + } + else { + length = new TextEncoder().encode(batch.join()).length; + result = await signalRClient.value.invoke<{ Success: boolean; Message: string; }>( + 'UploadEvents', batch, webfetcherType.value === 'direct' ? true : false + ); + } + if (result?.Success) { events.splice(0, batch.length); // 从队列中移除已成功发送的事件 successfulUploads.value++; - bytesSentSession.value += new TextEncoder().encode(batch.join()).length; + bytesSentSession.value += length; } else { failedUploads.value++; - logError(prefix.value + '上传弹幕失败: ' + result?.Message); + console.error(prefix.value + '上传弹幕失败: ' + result?.Message); } } catch (err) { failedUploads.value++; - logError(prefix.value + '发送事件时出错: ' + err); + console.error(prefix.value + '发送事件时出错: ' + err); } } @@ -379,6 +432,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => { startedAt, isStreaming: computed(() => streamingInfo.value?.status === 'streaming'), // 从 statistics 模块获取 webfetcherType, + signalRId, // 连接详情 danmakuClientState,