diff --git a/bun.lockb b/bun.lockb index 7e5b254..57090da 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/default.d.ts b/default.d.ts index 6f315f7..07b6bbc 100644 --- a/default.d.ts +++ b/default.d.ts @@ -1,5 +1,5 @@ -import type { LoadingBarProviderInst, MessageProviderInst, ModalProviderInst } from 'naive-ui' +import type { LoadingBarProviderInst, MessageProviderInst, ModalProviderInst, NotificationProviderInst } from 'naive-ui' import type { useRoute } from 'vue-router' declare module 'vue3-aplayer' { @@ -22,5 +22,6 @@ declare global { $route: ReturnType $modal: ModalProviderInst $mitt: Emitter + $notification: NotificationProviderInst } } diff --git a/package.json b/package.json index ebd26a1..b00c26f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,16 @@ "@microsoft/signalr-protocol-msgpack": "^8.0.7", "@mixer/postmessage-rpc": "^1.1.4", "@tauri-apps/api": "^2.4.0", + "@tauri-apps/plugin-autostart": "^2.3.0", "@tauri-apps/plugin-http": "^2.4.2", + "@tauri-apps/plugin-log": "^2.3.1", + "@tauri-apps/plugin-notification": "^2.2.2", + "@tauri-apps/plugin-opener": "^2.2.6", + "@tauri-apps/plugin-os": "^2.2.1", + "@tauri-apps/plugin-process": "^2.2.1", + "@tauri-apps/plugin-store": "^2.2.0", + "@tauri-apps/plugin-updater": "^2.7.0", + "@types/crypto-js": "^4.2.2", "@typescript-eslint/eslint-plugin": "^8.27.0", "@vicons/fluent": "^0.13.0", "@vitejs/plugin-basic-ssl": "^2.0.0", @@ -28,6 +37,7 @@ "@wangeditor/editor-for-vue": "^5.1.12", "bilibili-live-ws": "^6.3.1", "brotli-compress": "^1.3.3", + "crypto-js": "^4.2.0", "date-fns": "^4.1.0", "easy-speech": "^2.4.0", "echarts": "^5.6.0", diff --git a/src/App.vue b/src/App.vue index d5c25f5..f9c0111 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,75 +1,80 @@ diff --git a/src/api/account.ts b/src/api/account.ts index 2a6f74f..9da94d0 100644 --- a/src/api/account.ts +++ b/src/api/account.ts @@ -5,19 +5,20 @@ import { isSameDay } from 'date-fns' import { createDiscreteApi } from 'naive-ui' import { ref } from 'vue' import { APIRoot, AccountInfo, FunctionTypes } from './api-models' -import { useRoute } from 'vue-router' export const ACCOUNT = ref({} as AccountInfo) export const isLoadingAccount = ref(true) -const route = useRoute() +export const isLoggedIn = computed(() => { + return ACCOUNT.value.id > 0 +}) const { message } = createDiscreteApi(['message']) const cookie = useLocalStorage('JWT_Token', '') -const cookieRefreshDate = useLocalStorage('JWT_Token_Last_Refresh', Date.now()) +const cookieRefreshDate = useLocalStorage('JWT_Token_Last_Refresh', 0) -export async function GetSelfAccount() { - if (cookie.value) { - const result = await Self() +export async function GetSelfAccount(token?: string) { + if (cookie.value || token) { + const result = await Self(token) if (result.code == 200) { if (!ACCOUNT.value.id) { ACCOUNT.value = result.data @@ -28,7 +29,7 @@ export async function GetSelfAccount() { isLoadingAccount.value = false //console.log('[vtsuru] 已获取账户信息') if (!isSameDay(new Date(), cookieRefreshDate.value)) { - refreshCookie() + refreshCookie(token) } return result.data } else if (result.code == 401) { @@ -45,16 +46,17 @@ export async function GetSelfAccount() { } isLoadingAccount.value = false } + export function UpdateAccountLoop() { setInterval(() => { - if (ACCOUNT.value && route?.name != 'question-display') { + if (ACCOUNT.value && window.$route?.name != 'question-display') { // 防止在问题详情页刷新 GetSelfAccount() } }, 60 * 1000) } -function refreshCookie() { - QueryPostAPI(`${ACCOUNT_API_URL}refresh-token`).then((data) => { +function refreshCookie(token?: string) { + QueryPostAPIWithParams(`${ACCOUNT_API_URL}refresh-token`, { token }).then((data) => { if (data.code == 200) { cookie.value = data.data cookieRefreshDate.value = Date.now() @@ -155,8 +157,8 @@ export async function Login( password }) } -export async function Self(): Promise> { - return QueryPostAPI(`${ACCOUNT_API_URL}self`) +export async function Self(token?: string): Promise> { + return QueryPostAPIWithParams(`${ACCOUNT_API_URL}self`, token ? { token } : undefined) } export async function AddBiliBlackList( id: number, diff --git a/src/client/ClientFetcher.vue b/src/client/ClientFetcher.vue new file mode 100644 index 0000000..5465b82 --- /dev/null +++ b/src/client/ClientFetcher.vue @@ -0,0 +1,1113 @@ + + + \ No newline at end of file diff --git a/src/client/ClientIndex.vue b/src/client/ClientIndex.vue new file mode 100644 index 0000000..449a21f --- /dev/null +++ b/src/client/ClientIndex.vue @@ -0,0 +1,143 @@ + + + \ No newline at end of file diff --git a/src/client/ClientLayout.vue b/src/client/ClientLayout.vue new file mode 100644 index 0000000..ed24728 --- /dev/null +++ b/src/client/ClientLayout.vue @@ -0,0 +1,434 @@ + + + + + \ No newline at end of file diff --git a/src/client/ClientSettings.vue b/src/client/ClientSettings.vue new file mode 100644 index 0000000..c5dc5df --- /dev/null +++ b/src/client/ClientSettings.vue @@ -0,0 +1,320 @@ + + + + + \ No newline at end of file diff --git a/src/client/ClientTest.vue b/src/client/ClientTest.vue new file mode 100644 index 0000000..7da7315 --- /dev/null +++ b/src/client/ClientTest.vue @@ -0,0 +1,31 @@ + + + \ No newline at end of file diff --git a/src/client/WindowBar.vue b/src/client/WindowBar.vue new file mode 100644 index 0000000..ee25b7b --- /dev/null +++ b/src/client/WindowBar.vue @@ -0,0 +1,154 @@ + + + + + \ No newline at end of file diff --git a/src/client/data/biliLogin.ts b/src/client/data/biliLogin.ts new file mode 100644 index 0000000..f88dad1 --- /dev/null +++ b/src/client/data/biliLogin.ts @@ -0,0 +1,92 @@ +import { fetch } from '@tauri-apps/plugin-http'; +import { error } from '@tauri-apps/plugin-log' +import { QueryBiliAPI } from './utils'; + +export async function checkLoginStatusAsync(): Promise { + const url = 'https://api.bilibili.com/x/web-interface/nav/stat'; + const response = await fetch(url); + const json = await response.json(); + + return json.code === 0; +} + +export async function getUidAsync(): Promise { + const url = 'https://api.live.bilibili.com/xlive/web-ucenter/user/get_user_info'; + const response = await fetch(url); + const json = await response.json(); + + if (json.data && json.data.uid) { + return json.data.uid; + } + + return 0; +} +// 二维码地址及扫码密钥 +export async function getLoginUrlAsync(): Promise { + const url = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate'; + const response = await QueryBiliAPI(url, 'GET') + if (!response.ok) { + const result = await response.text(); + error('无法获取B站登陆二维码: ' + result); + throw new Error('获取二维码地址失败'); + } + return await response.json(); +} + +export async function getLoginUrlDataAsync(): Promise<{ + url: string; + qrcode_key: string; +}> { + const message = await getLoginUrlAsync(); + if (message.code !== 0) { + throw new Error('获取二维码地址失败'); + } + return message.data as { + url: string; + qrcode_key: string; + }; +} +type QRCodeLoginInfo = + | { status: 'expired' } + | { status: 'unknown' } + | { status: 'scanned' } + | { status: 'waiting' } + | { status: 'confirmed'; cookie: string; refresh_token: string }; +export async function getLoginInfoAsync(qrcodeKey: string): Promise { + const url = `https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=${qrcodeKey}&source=main-fe-header`; + const response = await QueryBiliAPI(url); + const message = await response.json(); + + if (!message.data) { + throw new Error('获取登录信息失败'); + } + + if (message.data.code !== 0) { + switch (message.data.code) { + case 86038: + return { status: 'expired' }; + case 86090: + return { status: 'scanned' }; + case 86101: + return { status: 'waiting' }; + default: + return { status: 'unknown' }; + } + } + + const cookies = response.headers.get('set-cookie'); + if (!cookies) { + throw new Error('无法获取 Cookie'); + } + + return { status: 'confirmed', cookie: extractCookie(cookies), refresh_token: message.data.refresh_token }; +} + +function extractCookie(cookies: string): string { + const cookieArray = cookies + .split(',') + .map((cookie) => cookie.split(';')[0].trim()) + .filter(Boolean); + const cookieSet = new Set(cookieArray); + return Array.from(cookieSet).join('; '); +} diff --git a/src/client/data/info.ts b/src/client/data/info.ts new file mode 100644 index 0000000..3e1bd89 --- /dev/null +++ b/src/client/data/info.ts @@ -0,0 +1,236 @@ +import { ref } from 'vue'; +import { format } from 'date-fns'; +import { info, error } from '@tauri-apps/plugin-log'; +import { QueryBiliAPI } from './utils'; // 假设 Bili API 工具路径 +import { BiliRoomInfo, BiliStreamingInfo, FetcherStatisticData } from './models'; // 假设模型路径 +import { useTauriStore } from '../store/useTauriStore'; +// import { useAccount } from '@/api/account'; // 如果需要账户信息 + +// const accountInfo = useAccount(); // 如果需要 + +export const STATISTIC_STORE_KEY = 'webfetcher.statistics'; + +/** + * 当前日期 (YYYY-MM-DD) 的统计数据 (会被持久化) + */ +export const currentStatistic = ref(); +/** + * 标记当前统计数据是否已更新且需要保存 + */ +export const shouldUpdateStatistic = ref(false); + +/** + * 直播流信息 (从B站API获取) + */ +export const streamingInfo = ref({ + status: 'prepare', // 初始状态 +} as BiliStreamingInfo); + +/** + * 房间基本信息 (从B站API获取) + */ +export const roomInfo = ref(); // 可以添加房间信息 + +// --- Bili API 更新相关 --- +const updateCount = ref(0); // 用于控制API调用频率的计数器 + +/** + * 初始化统计和信息获取逻辑 + */ +export function initInfo() { + // 立即执行一次以加载或初始化当天数据 + updateCallback(); + // 设置定时器,定期检查和保存统计数据,并更新直播间信息 + setInterval(() => { + updateCallback(); + }, 5000); // 每 5 秒检查一次统计数据保存和更新直播信息 +} + +/** + * 定时回调函数: 处理统计数据持久化和B站信息更新 + */ +async function updateCallback() { + const store = useTauriStore(); + const currentDate = format(new Date(), 'yyyy-MM-dd'); + const key = `${STATISTIC_STORE_KEY}.${currentDate}`; + + // --- 统计数据管理 --- + // 检查是否需要加载或初始化当天的统计数据 + if (!currentStatistic.value || currentStatistic.value.date !== currentDate) { + const loadedData = await store.get(key); + if (loadedData && loadedData.date === currentDate) { + currentStatistic.value = loadedData; + // 确保 eventTypeCounts 存在 + if (!currentStatistic.value.eventTypeCounts) { + currentStatistic.value.eventTypeCounts = {}; + } + // info(`Loaded statistics for ${currentDate}`); // 日志保持不变 + } else { + info(`Initializing statistics for new day: ${currentDate}`); + currentStatistic.value = { + date: currentDate, + count: 0, + eventTypeCounts: {}, // 初始化类型计数 + }; + await store.set(key, currentStatistic.value); // 立即保存新一天的初始结构 + shouldUpdateStatistic.value = false; // 重置保存标记 + + // 清理旧数据逻辑 (保持不变) + const allKeys = (await store.store.keys()).filter((k) => k.startsWith(STATISTIC_STORE_KEY)); + if (allKeys.length > 30) { // 例如,只保留最近30天的数据 + allKeys.sort(); // 按日期字符串升序排序 + const oldestKey = allKeys[0]; + await store.store.delete(oldestKey); + info('清理过期统计数据: ' + oldestKey); + } + } + } + + // 如果数据有更新,则保存 + if (shouldUpdateStatistic.value && currentStatistic.value) { + try { + await store.set(key, currentStatistic.value); + shouldUpdateStatistic.value = false; // 保存后重置标记 + } catch (err) { + error("Failed to save statistics: " + err); + } + } + + // --- B站信息更新 --- + let updateDelay = 30; // 默认30秒更新一次房间信息 + if (streamingInfo.value.status === 'streaming' && !import.meta.env.DEV) { + updateDelay = 15; // 直播中15秒更新一次 (可以适当调整) + } + // 使用取模运算控制调用频率 + if (updateCount.value % (updateDelay / 5) === 0) { // 因为主循环是5秒一次 + updateRoomAndStreamingInfo(); + } + updateCount.value++; +} + +/** + * 记录一个接收到的事件 (由 useWebFetcher 调用) + * @param eventType 事件类型字符串 (例如 "DANMU_MSG") + */ +export function recordEvent(eventType: string) { + const currentDate = format(new Date(), 'yyyy-MM-dd'); + + // 确保 currentStatistic 已为当天初始化 + if (!currentStatistic.value || currentStatistic.value.date !== currentDate) { + // 理论上 updateCallback 会先执行初始化,这里加个警告以防万一 + console.warn("recordEvent called before currentStatistic was initialized for today."); + // 可以选择在这里强制调用一次 updateCallback 来初始化,但这可能是异步的 + // await updateCallback(); // 可能会引入复杂性 + return; // 或者直接返回,丢失这个事件计数 + } + + // 增加总数 + currentStatistic.value.count++; + + // 增加对应类型的计数 + if (!currentStatistic.value.eventTypeCounts) { + currentStatistic.value.eventTypeCounts = {}; // 防御性初始化 + } + currentStatistic.value.eventTypeCounts[eventType] = (currentStatistic.value.eventTypeCounts[eventType] || 0) + 1; + + // 标记需要保存 + shouldUpdateStatistic.value = true; +} + +/** + * 从 command 数据中解析事件类型 + * (需要根据实际接收到的数据结构调整) + */ +export function getEventType(command: any): string { + if (typeof command === 'string') { + try { + command = JSON.parse(command); + } catch (e) { + return 'UNKNOWN_FORMAT'; + } + } + + if (command && typeof command === 'object') { + // 优先使用 'cmd' 字段 (常见于 Web 或 OpenLive) + if (command.cmd) return command.cmd; + // 备选 'command' 字段 + if (command.command) return command.command; + // 备选 'type' 字段 + if (command.type) return command.type; + } + return 'UNKNOWN'; // 未知类型 +} + +/** + * 获取指定天数的历史统计数据 + * @param days 要获取的天数,默认为 7 + */ +export async function getHistoricalStatistics(days: number = 7): Promise { + const store = useTauriStore(); + const keys = (await store.store.keys()) + .filter(key => key.startsWith(STATISTIC_STORE_KEY)) + .sort((a, b) => b.localeCompare(a)); // 按日期降序排序 + + const historicalData: FetcherStatisticData[] = []; + for (let i = 0; i < Math.min(days, keys.length); i++) { + const data = await store.get(keys[i]); + if (data) { + historicalData.push(data); + } + } + return historicalData.reverse(); // 返回按日期升序排列的结果 +} + +/** + * 更新房间和直播流信息 + */ +async function updateRoomAndStreamingInfo() { + // 需要一个房间ID来查询,这个ID可能来自设置、登录信息或固定配置 + // const roomId = accountInfo.value?.roomid ?? settings.value.roomId; // 示例:获取房间ID + const roomId = 21484828; // !!! 示例:这里需要替换成实际获取房间ID的逻辑 !!! + if (!roomId) { + // error("无法获取房间ID以更新直播信息"); + return; + } + + try { + // 查询房间基本信息 + const roomRes = await QueryBiliAPI( + `https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${roomId}` + ); + const json = await roomRes.json(); + if (json.code === 0) { + roomInfo.value = json.data; + } else { + error(`Failed to fetch Bili room info: ${json.message}`); + } + // 查询直播流信息 (开放平台或Web接口) + // 注意:这里可能需要根据所选模式(openlive/direct)调用不同的API + // 以下是Web接口示例 + const streamRes = await QueryBiliAPI( + `https://api.live.bilibili.com/room/v1/Room/get_status_info_by_uids?uids[]=${roomInfo.value?.uid}` // 通过 UID 查询 + // 或者使用 `https://api.live.bilibili.com/xlive/web-room/v1/index/getRoomBaseInfo?room_ids=${roomId}&req_biz=web_room_componet` + ); + const streamJson = await streamRes.json(); + if (streamJson.code === 0 && streamJson.data && roomInfo.value?.uid) { + // Web API 返回的是一个以 UID 为 key 的对象 + const uidData = streamJson.data[roomInfo.value.uid.toString()]; + if (uidData) { + streamingInfo.value = { + ...uidData, // 合并获取到的数据 + status: uidData.live_status === 1 ? 'streaming' : uidData.live_status === 2 ? 'rotating' : 'prepare', + }; + } else { + // 如果没有对应UID的数据,可能表示未开播或接口变更 + //streamingInfo.value = { status: 'prepare', ...streamingInfo.value }; // 保留旧数据,状态设为prepare + } + } else if (streamJson.code !== 0) { + error(`Failed to fetch Bili streaming info: ${streamJson.message}`); + // 可选:如果获取失败,将状态设为未知或准备 + // streamingInfo.value = { status: 'prepare', ...streamingInfo.value }; + } + + } catch (err) { + error("Error updating room/streaming info: " + err); + } +} \ No newline at end of file diff --git a/src/client/data/initialize.ts b/src/client/data/initialize.ts new file mode 100644 index 0000000..2a38970 --- /dev/null +++ b/src/client/data/initialize.ts @@ -0,0 +1,243 @@ +import { isLoggedIn, useAccount } from "@/api/account"; +import { attachConsole, info, warn } from "@tauri-apps/plugin-log"; +import { useSettings } from "../store/useSettings"; +import { useWebFetcher } from "@/store/useWebFetcher"; +import { useBiliCookie } from "../store/useBiliCookie"; +import { getBuvid, getRoomKey } from "./utils"; +import { initInfo } from "./info"; +import { TrayIcon, TrayIconOptions } from '@tauri-apps/api/tray'; +import { Menu } from "@tauri-apps/api/menu"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { + isPermissionGranted, + onAction, + requestPermission, + sendNotification, +} from '@tauri-apps/plugin-notification'; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { CN_HOST } from "@/data/constants"; +import { invoke } from "@tauri-apps/api/core"; +import { check } from '@tauri-apps/plugin-updater'; +import { relaunch } from '@tauri-apps/plugin-process'; + +const accountInfo = useAccount(); + +export const clientInited = ref(false); +let tray: TrayIcon; +export async function initAll() { + if (clientInited.value) { + return; + } + let permissionGranted = await isPermissionGranted(); + + // If not we need to request it + if (!permissionGranted) { + const permission = await requestPermission(); + permissionGranted = permission === 'granted'; + if (permissionGranted) { + info('Notification permission granted'); + } + + } + initNotificationHandler(); + const detach = await attachConsole(); + const settings = useSettings(); + const biliCookie = useBiliCookie(); + await settings.init(); + info('[init] 已加载账户信息'); + biliCookie.init(); + info('[init] 已加载bilibili cookie'); + initInfo(); + info('[init] 开始更新数据'); + + if (isLoggedIn && accountInfo.value.isBiliVerified) { + const danmakuInitNoticeRef = window.$notification.info({ + title: '正在初始化弹幕客户端...', + closable: false + }); + const result = await initDanmakuClient(); + danmakuInitNoticeRef.destroy(); + if (result.success) { + window.$notification.success({ + title: '弹幕客户端初始化完成', + duration: 3000 + }); + } else { + window.$notification.error({ + title: '弹幕客户端初始化失败: ' + result.message, + }); + } + } + info('[init] 已加载弹幕客户端'); + // 初始化系统托盘图标和菜单 + const menu = await Menu.new({ + items: [ + { + id: 'quit', + text: '退出', + action: () => { + invoke('quit_app'); + }, + }, + ], + }); + const iconData = await (await fetch('https://oss.suki.club/vtsuru/icon.ico')).arrayBuffer(); + const appWindow = getCurrentWindow(); + const options: TrayIconOptions = { + // here you can add a tray menu, title, tooltip, event handler, etc + menu: menu, + title: 'VTsuru.Client', + tooltip: 'VTsuru 事件收集器', + icon: iconData, + action: (event) => { + + switch (event.type) { + case 'DoubleClick': + appWindow.show(); + break; + case 'Click': + appWindow.show(); + break; + } + } + }; + + + tray = await TrayIcon.new(options); + + clientInited.value = true; +} +export function OnClientUnmounted() { + if (clientInited.value) { + clientInited.value = false; + } + + tray.close(); +} + +async function checkUpdate() { + const update = await check(); + if (update) { + console.log( + `found update ${update.version} from ${update.date} with notes ${update.body}` + ); + let downloaded = 0; + let contentLength = 0; + // alternatively we could also call update.download() and update.install() separately + await update.downloadAndInstall((event) => { + switch (event.event) { + case 'Started': + contentLength = event.data.contentLength || 0; + console.log(`started downloading ${event.data.contentLength} bytes`); + break; + case 'Progress': + downloaded += event.data.chunkLength; + console.log(`downloaded ${downloaded} from ${contentLength}`); + break; + case 'Finished': + console.log('download finished'); + break; + } + }); + + console.log('update installed'); + await relaunch(); + } +} + +export const isInitedDanmakuClient = ref(false); +export const isInitingDanmakuClient = ref(false); +export async function initDanmakuClient() { + const biliCookie = useBiliCookie(); + const settings = useSettings(); + if (isInitedDanmakuClient.value || isInitingDanmakuClient.value) { + return { success: true, message: '' }; + } + isInitingDanmakuClient.value = true; + let result = { success: false, message: '' }; + try { + if (isLoggedIn) { + if (settings.settings.useDanmakuClientType === 'openlive') { + result = await initOpenLive(); + } else { + const cookie = await biliCookie.getBiliCookie(); + if (!cookie) { + if (settings.settings.fallbackToOpenLive) { + settings.settings.useDanmakuClientType = 'openlive'; + settings.save(); + info('未设置bilibili cookie, 根据设置切换为openlive'); + result = await initOpenLive(); + } else { + info('未设置bilibili cookie, 跳过弹幕客户端初始化'); + window.$notification.warning({ + title: '未设置bilibili cookie, 跳过弹幕客户端初始化', + duration: 5, + }); + result = { success: false, message: '未设置bilibili cookie' }; + } + } else { + const resp = await callStartDanmakuClient(); + if (!resp?.success) { + warn('加载弹幕客户端失败: ' + resp?.message); + result = { success: false, message: resp?.message }; + } else { + info('已加载弹幕客户端'); + result = { success: true, message: '' }; + } + } + } + } + return result; + } catch (err) { + warn('加载弹幕客户端失败: ' + err); + return { success: false, message: '加载弹幕客户端失败' }; + } finally { + if (result) { + isInitedDanmakuClient.value = true; + } + isInitingDanmakuClient.value = false; + } +} +export async function initOpenLive() { + const reuslt = await callStartDanmakuClient(); + if (reuslt?.success == true) { + info('已加载弹幕客户端 [openlive]'); + } else { + warn('加载弹幕客户端失败 [openlive]: ' + reuslt?.message); + } + return reuslt; +} +function initNotificationHandler(){ + onAction((event) => { + if (event.extra?.type === 'question-box') { + openUrl(CN_HOST + '/manage/question-box'); + } + }); +} + +export async function callStartDanmakuClient() { + const biliCookie = useBiliCookie(); + const settings = useSettings(); + const webFetcher = useWebFetcher(); + if (settings.settings.useDanmakuClientType === 'direct') { + const key = await getRoomKey( + accountInfo.value.biliRoomId!, await biliCookie.getBiliCookie() || ''); + if (!key) { + warn('获取房间密钥失败, 无法连接弹幕客户端'); + return { success: false, message: '无法获取房间密钥' }; + } + const buvid = await getBuvid(); + if (!buvid) { + warn('获取buvid失败, 无法连接弹幕客户端'); + return { success: false, message: '无法获取buvid' }; + } + return await webFetcher.Start('direct', { + roomId: accountInfo.value.biliRoomId!, + buvid: buvid.data, + token: key, + tokenUserId: biliCookie.uId!, + }, true); + } else { + return await webFetcher.Start('openlive', undefined, true); + } +} \ No newline at end of file diff --git a/src/client/data/models.ts b/src/client/data/models.ts new file mode 100644 index 0000000..a6a29cb --- /dev/null +++ b/src/client/data/models.ts @@ -0,0 +1,282 @@ +export interface EventFetcherStateModel { + online: boolean; + status: { [errorCode: string]: string }; + version?: string; + todayReceive: number; + useCookie: boolean; + type: EventFetcherType; +} + +export enum EventFetcherType { + Application, + OBS, + Server, + Tauri, +} + +export type BiliRoomInfo = { + uid: number; + room_id: number; + short_id: number; + attention: number; + online: number; + is_portrait: boolean; + description: string; + live_status: number; + area_id: number; + parent_area_id: number; + parent_area_name: string; + old_area_id: number; + background: string; + title: string; + user_cover: string; + keyframe: string; + is_strict_room: boolean; + live_time: string; + tags: string; + is_anchor: number; + room_silent_type: string; + room_silent_level: number; + room_silent_second: number; + area_name: string; + pendants: string; + area_pendants: string; + hot_words: string[]; + hot_words_status: number; + verify: string; + new_pendants: { + frame: { + name: string; + value: string; + position: number; + desc: string; + area: number; + area_old: number; + bg_color: string; + bg_pic: string; + use_old_area: boolean; + }; + badge: unknown; // null in the example, adjust to proper type if known + mobile_frame: { + name: string; + value: string; + position: number; + desc: string; + area: number; + area_old: number; + bg_color: string; + bg_pic: string; + use_old_area: boolean; + }; + mobile_badge: unknown; // null in the example, adjust to proper type if known + }; + up_session: string; + pk_status: number; + pk_id: number; + battle_id: number; + allow_change_area_time: number; + allow_upload_cover_time: number; + studio_info: { + status: number; + master_list: any[]; // empty array in the example, adjust to proper type if known + }; +} + +export type FetcherStatisticData = { + date: string; + count: number; + eventTypeCounts: { [eventType: string]: number }; +}; +export type BiliStreamingInfo = { + status: 'prepare' | 'streaming' | 'cycle'; + streamAt: Date; + roomId: number; + title: string; + coverUrl: string; + frameUrl: string; + areaName: string; + parentAreaName: string; + online: number; + attention: number; +}; + +// Nested type for Vip Label +interface VipLabel { + path: string; + text: string; + label_theme: string; + text_color: string; + bg_style: number; + bg_color: string; + border_color: string; + use_img_label: boolean; + img_label_uri_hans: string; + img_label_uri_hant: string; + img_label_uri_hans_static: string; + img_label_uri_hant_static: string; +} + +// Nested type for Avatar Icon +interface AvatarIcon { + icon_type: number; + // Assuming icon_resource could contain arbitrary data or be empty + icon_resource: Record | {}; +} + +// Nested type for Vip Info +interface VipInfo { + type: number; + status: number; + due_date: number; // Likely a Unix timestamp in milliseconds + vip_pay_type: number; + theme_type: number; + label: VipLabel; + avatar_subscript: number; + nickname_color: string; + role: number; + avatar_subscript_url: string; + tv_vip_status: number; + tv_vip_pay_type: number; + tv_due_date: number; // Likely a Unix timestamp in milliseconds or 0 + avatar_icon: AvatarIcon; +} + +// Nested type for Pendant Info +interface PendantInfo { + pid: number; + name: string; + image: string; // URL + expire: number; // Likely a timestamp or duration + image_enhance: string; // URL + image_enhance_frame: string; // URL or empty string + n_pid: number; +} + +// Nested type for Nameplate Info +interface NameplateInfo { + nid: number; + name: string; + image: string; // URL + image_small: string; // URL + level: string; + condition: string; +} + +// Nested type for Official Info +interface OfficialInfo { + role: number; + title: string; + desc: string; + type: number; +} + +// Nested type for Profession Info +interface ProfessionInfo { + id: number; + name: string; + show_name: string; + is_show: number; // Likely 0 or 1 + category_one: string; + realname: string; + title: string; + department: string; + certificate_no: string; + certificate_show: boolean; +} + +// Nested type for Honours Colour +interface HonoursColour { + dark: string; // Hex color code + normal: string; // Hex color code +} + +// Nested type for Honours Info +interface HonoursInfo { + mid: number; + colour: HonoursColour; + // Assuming tags could be an array of strings if not null + tags: string[] | null; + is_latest_100honour: number; // Likely 0 or 1 +} + +// Nested type for Attestation Common Info +interface CommonAttestationInfo { + title: string; + prefix: string; + prefix_title: string; +} + +// Nested type for Attestation Splice Info +interface SpliceAttestationInfo { + title: string; +} + +// Nested type for Attestation Info +interface AttestationInfo { + type: number; + common_info: CommonAttestationInfo; + splice_info: SpliceAttestationInfo; + icon: string; + desc: string; +} + +// Nested type for Expert Info +interface ExpertInfo { + title: string; + state: number; + type: number; + desc: string; +} + +// Nested type for Level Exp Info +interface LevelExpInfo { + current_level: number; + current_min: number; + current_exp: number; + next_exp: number; // -1 might indicate max level or data not applicable + level_up: number; // Likely a Unix timestamp +} + +// Main User Profile Type +export type BiliUserProfile = { + mid: number; + name: string; + sex: string; // Could be more specific like '男' | '女' | '保密' if desired + face: string; // URL + sign: string; + rank: number; + level: number; + jointime: number; // Likely a Unix timestamp or 0 + moral: number; + silence: number; // Likely 0 or 1 + email_status: number; // Likely 0 or 1 + tel_status: number; // Likely 0 or 1 + identification: number; // Likely 0 or 1 + vip: VipInfo; + pendant: PendantInfo; + nameplate: NameplateInfo; + official: OfficialInfo; + birthday: number; // Likely a Unix timestamp + is_tourist: number; // Likely 0 or 1 + is_fake_account: number; // Likely 0 or 1 + pin_prompting: number; // Likely 0 or 1 + is_deleted: number; // Likely 0 or 1 + in_reg_audit: number; // Likely 0 or 1 + is_rip_user: boolean; + profession: ProfessionInfo; + face_nft: number; + face_nft_new: number; + is_senior_member: number; // Likely 0 or 1 + honours: HonoursInfo; + digital_id: string; + digital_type: number; + attestation: AttestationInfo; + expert_info: ExpertInfo; + // Assuming name_render could be various types or null + name_render: any | null; + country_code: string; + level_exp: LevelExpInfo; + coins: number; // Can be float + following: number; + follower: number; +}; \ No newline at end of file diff --git a/src/client/data/notification.ts b/src/client/data/notification.ts new file mode 100644 index 0000000..217e35f --- /dev/null +++ b/src/client/data/notification.ts @@ -0,0 +1,27 @@ +import { QAInfo } from "@/api/api-models"; +import { useSettings } from "../store/useSettings"; +import { isPermissionGranted, onAction, sendNotification } from "@tauri-apps/plugin-notification"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { CN_HOST } from "@/data/constants"; + +export async function onReceivedQuestion(question: QAInfo) { + const setting = useSettings(); + if (setting.settings.notificationSettings.enableTypes.includes("question-box")) { + window.$notification.info({ + title: "收到提问", + description: '收到来自 [' + question.sender.name || '匿名用户' + '] 的提问', + duration: 5, + }); + let permissionGranted = await isPermissionGranted(); + if (permissionGranted) { + sendNotification({ + title: "收到提问", + body: '来自 [' + question.sender.name || '匿名用户' + '] 的提问', + silent: false, + extra: { type: 'question-box' }, + }); + + } + } + +} \ No newline at end of file diff --git a/src/client/data/utils.ts b/src/client/data/utils.ts new file mode 100644 index 0000000..6301b72 --- /dev/null +++ b/src/client/data/utils.ts @@ -0,0 +1,77 @@ +import { fetch } from '@tauri-apps/plugin-http'; +import { useBiliCookie } from '../store/useBiliCookie'; +import { QueryPostAPI } from '@/api/query'; +import { OPEN_LIVE_API_URL } from '@/data/constants'; +import { error } from '@tauri-apps/plugin-log'; + +export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '') { + const u = new URL(url); + return fetch(url, { + method: method, + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', + Origin: '', + Cookie: cookie || (await useBiliCookie().getBiliCookie()) || '', + 'Upgrade-Insecure-Requests': '1', + }, + }); +} + +export async function getRoomKey(roomId: number, cookie: string) { + try { + const result = await QueryBiliAPI( + 'https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=' + roomId + ); + const json = await result.json(); + if (json.code === 0) return json.data.token; + else { + error(`无法获取直播间key: ${json.message}`); + } + } catch (err) { + error(`无法获取直播间key: ${err}`); + } +} +export async function getBuvid() { + try { + const result = await QueryBiliAPI('https://api.bilibili.com/x/web-frontend/getbuvid'); + if (result.ok) { + const json = await result.json(); + if (json.code === 0) return json.data.buvid; + else { + error(`无法获取buvid: ${json.message}`); + } + } else { + error(`无法获取buvid: ${result.statusText}`); + } + } catch (err) { + error(`无法获取buvid: ${err}`); + } +} + +export async function getAuthInfo(): Promise<{ + data: any; + message: string; +}> { + try { + const data = await QueryPostAPI(OPEN_LIVE_API_URL + 'start'); + if (data.code == 200) { + console.log(`[open-live] 已获取认证信息`); + return { + data: data.data, + message: '', + }; + } else { + return { + data: null, + message: data.message, + }; + } + } catch (err) { + return { + data: null, + message: err?.toString() || '未知错误', + }; + } +} + diff --git a/src/client/store/useBiliCookie.ts b/src/client/store/useBiliCookie.ts new file mode 100644 index 0000000..598103c --- /dev/null +++ b/src/client/store/useBiliCookie.ts @@ -0,0 +1,581 @@ +import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; +import { useTauriStore } from './useTauriStore'; +import { error, info, warn, debug } from '@tauri-apps/plugin-log'; +import { AES, enc, MD5 } from 'crypto-js'; +import { QueryBiliAPI } from '../data/utils'; +import { BiliUserProfile } from '../data/models'; +import { defineStore, acceptHMRUpdate } from 'pinia'; +import { ref, computed, shallowRef } from 'vue'; + +// --- 常量定义 --- +// Tauri Store 存储键名 +export const BILI_COOKIE_KEY = 'user.bilibili.cookie'; +export const COOKIE_CLOUD_KEY = 'user.bilibili.cookie_cloud'; +export const USER_INFO_CACHE_KEY = 'cache.bilibili.userInfo'; + +// 检查周期 (毫秒) +const REGULAR_CHECK_INTERVAL = 60 * 1000; // 每分钟检查一次 Cookie 有效性 +const CLOUD_SYNC_INTERVAL_CHECKS = 30; // 每 30 次常规检查后 (约 30 分钟) 同步一次 CookieCloud + +// 用户信息缓存有效期 (毫秒) +const USER_INFO_CACHE_DURATION = 5 * 60 * 1000; // 缓存 5 分钟 + +// --- 类型定义 --- + +// Bilibili Cookie 存储数据结构 +type BiliCookieStoreData = { + cookie: string; + refreshToken?: string; // refreshToken 似乎未使用,设为可选 + lastRefresh?: Date; // 上次刷新时间,似乎未使用,设为可选 +}; + +// Cookie Cloud 配置数据结构 +export type CookieCloudConfig = { + key: string; + password: string; + host?: string; // CookieCloud 服务地址,可选,有默认值 +}; + +// CookieCloud 导出的 Cookie 单项结构 +export interface CookieCloudCookie { + domain: string; + expirationDate: number; + hostOnly: boolean; + httpOnly: boolean; + name: string; + path: string; + sameSite: string; + secure: boolean; + session: boolean; + storeId: string; + value: string; +} + +// CookieCloud 导出的完整数据结构 +interface CookieCloudExportData { + cookie_data: Record; // 按域名分组的 Cookie 数组 + local_storage_data?: Record; // 本地存储数据 (可选) + update_time: string; // 更新时间 ISO 8601 字符串 +} + +// 用户信息缓存结构 +type UserInfoCache = { + userInfo: BiliUserProfile; + accessedAt: number; // 使用时间戳 (Date.now()) 以方便比较 +}; + +// CookieCloud 状态类型 +type CookieCloudState = 'unset' | 'valid' | 'invalid' | 'syncing'; + +// --- Store 定义 --- + +export const useBiliCookie = defineStore('biliCookie', () => { + // --- 依赖和持久化存储实例 --- + // 使用 useTauriStore 获取持久化存储目标 + const biliCookieStore = useTauriStore().getTarget(BILI_COOKIE_KEY); + const cookieCloudStore = useTauriStore().getTarget(COOKIE_CLOUD_KEY); + const userInfoCacheStore = useTauriStore().getTarget(USER_INFO_CACHE_KEY); + + // --- 核心状态 --- + // 使用 shallowRef 存储用户信息对象,避免不必要的深度侦听,提高性能 + const _cachedUserInfo = shallowRef(); + // 是否已从存储加载了 Cookie (不代表有效) + const hasBiliCookie = ref(false); + // 当前 Cookie 是否通过 Bilibili API 验证有效 + const isCookieValid = ref(false); + // CookieCloud 配置及同步状态 + const cookieCloudState = ref('unset'); + // Bilibili 用户 ID + const uId = ref(); + + // --- 计算属性 --- + // 公开的用户信息,只读 + const userInfo = computed(() => _cachedUserInfo.value?.userInfo); + + // --- 内部状态和变量 --- + let _isInitialized = false; // 初始化标志,防止重复执行 + let _checkIntervalId: ReturnType | null = null; // 定时检查器 ID + let _checkCounter = 0; // 常规检查计数器,用于触发 CookieCloud 同步 + + // --- 私有辅助函数 --- + + /** + * @description 更新并持久化用户信息缓存 + * @param data Bilibili 用户信息 + */ + const _updateUserInfoCache = async (data: BiliUserProfile): Promise => { + const cacheData: UserInfoCache = { userInfo: data, accessedAt: Date.now() }; + _cachedUserInfo.value = cacheData; // 更新内存缓存 + uId.value = data.mid; // 更新 uId + try { + await userInfoCacheStore.set(cacheData); // 持久化缓存 + debug('[BiliCookie] 用户信息缓存已更新并持久化'); + } catch (err) { + error('[BiliCookie] 持久化用户信息缓存失败: ' + String(err)); + } + }; + + /** + * @description 清除用户信息缓存 (内存和持久化) + */ + const _clearUserInfoCache = async (): Promise => { + _cachedUserInfo.value = undefined; // 清除内存缓存 + uId.value = undefined; // 清除 uId + try { + await userInfoCacheStore.delete(); // 删除持久化缓存 + debug('[BiliCookie] 用户信息缓存已清除'); + } catch (err) { + error('[BiliCookie] 清除持久化用户信息缓存失败: ' + String(err)); + } + }; + + /** + * @description 更新 Cookie 存在状态和有效状态 + * @param hasCookie Cookie 是否存在 + * @param isValid Cookie 是否有效 + */ + const _updateCookieState = (hasCookie: boolean, isValid: boolean): void => { + hasBiliCookie.value = hasCookie; + isCookieValid.value = isValid; + if (!hasCookie || !isValid) { + // 如果 Cookie 不存在或无效,清除可能过时的用户信息缓存 + // 注意:这里采取了更严格的策略,无效则清除缓存,避免显示旧信息 + // _clearUserInfoCache(); // 考虑是否在无效时立即清除缓存 + debug(`[BiliCookie] Cookie 状态更新: hasCookie=${hasCookie}, isValid=${isValid}`); + } + }; + + /** + * @description 检查提供的 Bilibili Cookie 是否有效 + * @param cookie 要验证的 Cookie 字符串 + * @returns Promise<{ valid: boolean; data?: BiliUserProfile }> 验证结果和用户信息 (如果有效) + */ + const _checkCookieValidity = async (cookie: string): Promise<{ valid: boolean; data?: BiliUserProfile; }> => { + if (!cookie) { + return { valid: false }; + } + try { + // 使用传入的 cookie 调用 Bilibili API + const resp = await QueryBiliAPI('https://api.bilibili.com/x/space/myinfo', 'GET', cookie); + + const json = await resp.json(); + if (json.code === 0 && json.data) { + debug('[BiliCookie] Cookie 验证成功, 用户:', json.data.name); + // 验证成功,更新用户信息缓存 + await _updateUserInfoCache(json.data); + return { valid: true, data: json.data }; + } else { + warn(`[BiliCookie] Cookie 验证失败 (API 返回): ${json.message || `code: ${json.code}`}`); + return { valid: false }; + } + } catch (err) { + error('[BiliCookie] 验证 Cookie 时请求 Bilibili API 出错: ' + String(err)); + return { valid: false }; + } + }; + + + /** + * @description 从 CookieCloud 服务获取并解密 Bilibili Cookie + * @param config CookieCloud 配置 (如果提供,则使用此配置;否则使用已存储的配置) + * @returns Promise Bilibili Cookie 字符串 + * @throws 如果配置缺失、网络请求失败、解密失败或未找到 Bilibili Cookie,则抛出错误 + */ + const _fetchAndDecryptFromCloud = async (config?: CookieCloudConfig): Promise => { + const cloudConfig = config ?? await cookieCloudStore.get(); // 获取配置 + + if (!cloudConfig?.key || !cloudConfig?.password) { + throw new Error("CookieCloud 配置不完整 (缺少 Key 或 Password)"); + } + + const host = cloudConfig.host || "https://cookie.vtsuru.live"; // 默认 Host + const url = new URL(host); + url.pathname = `/get/${cloudConfig.key}`; + + info(`[BiliCookie] 正在从 CookieCloud (${url.hostname}) 获取 Cookie...`); + + try { + // 注意: 浏览器环境通常无法直接设置 User-Agent + // 使用 Tauri fetch 发送请求 + const response = await tauriFetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' // 根据 CookieCloud API 要求可能需要调整 + } + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`CookieCloud 请求失败: ${response.status} ${response.statusText}. ${errorText}`); + } + + const json = await response.json() as any; // 类型断言需要谨慎 + + if (json.encrypted) { + // 执行解密 + try { + const keyMaterial = MD5(cloudConfig.key + '-' + cloudConfig.password).toString(); + const decryptionKey = keyMaterial.substring(0, 16); // 取前16位作为 AES 密钥 + const decrypted = AES.decrypt(json.encrypted, decryptionKey).toString(enc.Utf8); + + if (!decrypted) { + throw new Error("解密结果为空,可能是密钥不匹配"); + } + + const cookieData = JSON.parse(decrypted) as CookieCloudExportData; + + // 提取 bilibili.com 的 Cookie + const biliCookies = cookieData.cookie_data?.['bilibili.com']; + if (!biliCookies || biliCookies.length === 0) { + throw new Error("在 CookieCloud 数据中未找到 'bilibili.com' 的 Cookie"); + } + + // 拼接 Cookie 字符串 + const cookieString = biliCookies + .map(c => `${c.name}=${c.value}`) + .join('; '); + + info('[BiliCookie] CookieCloud Cookie 获取并解密成功'); + return cookieString; + + } catch (decryptErr) { + error('[BiliCookie] CookieCloud Cookie 解密失败: ' + String(decryptErr)); + throw new Error(`Cookie 解密失败: ${decryptErr instanceof Error ? decryptErr.message : String(decryptErr)}`); + } + } else if (json.cookie_data) { + // 处理未加密的情况 (如果 CookieCloud 支持) + warn('[BiliCookie] 从 CookieCloud 收到未加密的 Cookie 数据'); + const biliCookies = (json as CookieCloudExportData).cookie_data?.['bilibili.com']; + if (!biliCookies || biliCookies.length === 0) { + throw new Error("在 CookieCloud 数据中未找到 'bilibili.com' 的 Cookie"); + } + const cookieString = biliCookies + .map(c => `${c.name}=${c.value}`) + .join('; '); + return cookieString; + } + else { + // API 返回了非预期的数据结构 + throw new Error(json.message || "从 CookieCloud 获取 Cookie 失败,响应格式不正确"); + } + } catch (networkErr) { + error('[BiliCookie] 请求 CookieCloud 时出错: ' + String(networkErr)); + throw new Error(`请求 CookieCloud 时出错: ${networkErr instanceof Error ? networkErr.message : String(networkErr)}`); + } + }; + + /** + * @description 从已配置的 CookieCloud 同步 Cookie,并更新本地状态 + * @returns Promise 是否同步并验证成功 + */ + const _syncFromCookieCloud = async (): Promise => { + const config = await cookieCloudStore.get(); + if (!config?.key) { + debug('[BiliCookie] 未配置 CookieCloud 或缺少 key,跳过同步'); + // 如果从未设置过,保持 unset;如果之前设置过但现在无效,标记为 invalid + if (cookieCloudState.value !== 'unset') { + cookieCloudState.value = 'invalid'; // 假设配置被清空意味着无效 + } + return false; + } + + cookieCloudState.value = 'syncing'; // 标记为同步中 + try { + const cookieString = await _fetchAndDecryptFromCloud(config); + // 验证从 Cloud 获取的 Cookie + const validationResult = await _checkCookieValidity(cookieString); + + if (validationResult.valid) { + // 验证成功,保存 Cookie + await setBiliCookie(cookieString); // setBiliCookie 内部会处理状态更新和持久化 + cookieCloudState.value = 'valid'; // 标记为有效 + info('[BiliCookie] 从 CookieCloud 同步并验证 Cookie 成功'); + return true; + } else { + // 从 Cloud 获取的 Cookie 无效 + warn('[BiliCookie] 从 CookieCloud 获取的 Cookie 无效'); + cookieCloudState.value = 'invalid'; // 标记为无效 + // 不更新本地 Cookie,保留当前有效的或无效的状态 + _updateCookieState(hasBiliCookie.value, false); // 显式标记当前cookie状态可能因云端无效而变为无效 + return false; + } + } catch (err) { + error('[BiliCookie] CookieCloud 同步失败: ' + String(err)); + cookieCloudState.value = 'invalid'; // 同步出错,标记为无效 + // 同步失败不应影响当前的 isCookieValid 状态,除非需要强制失效 + // _updateCookieState(hasBiliCookie.value, false); // 可选:同步失败时强制本地cookie失效 + return false; + } + }; + + + // --- 公开方法 --- + + /** + * @description 初始化 BiliCookie Store + * - 加载持久化数据 (Cookie, Cloud 配置, 用户信息缓存) + * - 检查 CookieCloud 配置状态 + * - 进行首次 Cookie 有效性检查 (或使用缓存) + * - 启动定时检查任务 + */ + const init = async (): Promise => { + if (_isInitialized) { + debug('[BiliCookie] Store 已初始化,跳过'); + return; + } + _isInitialized = true; + info('[BiliCookie] Store 初始化开始...'); + + // 1. 加载持久化数据 + const [storedCookieData, storedCloudConfig, storedUserInfo] = await Promise.all([ + biliCookieStore.get(), + cookieCloudStore.get(), + userInfoCacheStore.get(), + ]); + + // 2. 处理 CookieCloud 配置 + if (storedCloudConfig?.key && storedCloudConfig?.password) { + // 这里仅设置初始状态,有效性将在后续检查或同步中确认 + cookieCloudState.value = 'valid'; // 假设配置存在即可能有效,待验证 + info('[BiliCookie] 检测到已配置 CookieCloud'); + } else { + cookieCloudState.value = 'unset'; + info('[BiliCookie] 未配置 CookieCloud'); + } + + // 3. 处理用户信息缓存 + if (storedUserInfo && (Date.now() - storedUserInfo.accessedAt < USER_INFO_CACHE_DURATION)) { + _cachedUserInfo.value = storedUserInfo; + uId.value = storedUserInfo.userInfo.mid; + info(`[BiliCookie] 从缓存加载有效用户信息: UID=${uId.value}`); + // 如果缓存有效,可以初步认为 Cookie 是有效的 (至少在缓存有效期内是) + _updateCookieState(!!storedCookieData?.cookie, true); + } else { + info('[BiliCookie] 无有效用户信息缓存'); + _updateCookieState(!!storedCookieData?.cookie, false); // 默认无效,待检查 + if (storedUserInfo) { + // 如果有缓存但已过期,清除它 + await _clearUserInfoCache(); + } + } + + + // 4. 处理 Bilibili Cookie + if (storedCookieData?.cookie) { + hasBiliCookie.value = true; // 标记存在 Cookie + info('[BiliCookie] 检测到已存储的 Bilibili Cookie'); + // 检查 Cookie 有效性,除非用户信息缓存有效且未过期 + if (!_cachedUserInfo.value) { // 只有在没有有效缓存时才立即检查 + debug('[BiliCookie] 无有效缓存,立即检查 Cookie 有效性...'); + const { valid } = await _checkCookieValidity(storedCookieData.cookie); + _updateCookieState(true, valid); // 更新状态 + } + } else { + _updateCookieState(false, false); // 没有 Cookie,自然无效 + info('[BiliCookie] 未找到存储的 Bilibili Cookie'); + } + + + // 5. 启动定时检查器 + if (_checkIntervalId) { + clearInterval(_checkIntervalId); // 清除旧的定时器 (理论上不应存在) + } + _checkIntervalId = setInterval(check, REGULAR_CHECK_INTERVAL); + info(`[BiliCookie] 定时检查已启动,周期: ${REGULAR_CHECK_INTERVAL / 1000} 秒`); + + info('[BiliCookie] Store 初始化完成'); + }; + + /** + * @description 定期检查 Cookie 有效性,并按需从 CookieCloud 同步 + * @param forceCheckCloud 是否强制立即尝试从 CookieCloud 同步 (通常由 init 调用) + */ + const check = async (forceCheckCloud: boolean = false): Promise => { + debug('[BiliCookie] 开始周期性检查...'); + _checkCounter++; + + let cloudSyncAttempted = false; + let cloudSyncSuccess = false; + + // 检查是否需要从 CookieCloud 同步 + const shouldSyncCloud = forceCheckCloud || (_checkCounter % CLOUD_SYNC_INTERVAL_CHECKS === 0); + + if (shouldSyncCloud && cookieCloudState.value !== 'unset' && cookieCloudState.value !== 'syncing') { + info(`[BiliCookie] 触发 CookieCloud 同步 (计数: ${_checkCounter}, 强制: ${forceCheckCloud})`); + cloudSyncAttempted = true; + cloudSyncSuccess = await _syncFromCookieCloud(); + // 同步后重置计数器,避免连续同步 + _checkCounter = 0; + } + + // 如果没有尝试云同步,或者云同步失败,则检查本地 Cookie + if (!cloudSyncAttempted || !cloudSyncSuccess) { + debug('[BiliCookie] 检查本地存储的 Cookie 有效性...'); + const storedCookie = (await biliCookieStore.get())?.cookie; + if (storedCookie) { + const { valid } = await _checkCookieValidity(storedCookie); + // 只有在云同步未成功时才更新状态,避免覆盖云同步设置的状态 + if (!cloudSyncSuccess) { + _updateCookieState(true, valid); + } + } else { + // 本地没有 Cookie + _updateCookieState(false, false); + // 如果本地没 cookie 但 cookieCloud 配置存在且非 syncing, 尝试一次同步 + if (!cloudSyncAttempted && cookieCloudState.value !== 'unset' && cookieCloudState.value !== 'syncing') { + info('[BiliCookie] 本地无 Cookie,尝试从 CookieCloud 获取...'); + await _syncFromCookieCloud(); // 尝试获取一次 + _checkCounter = 0; // 同步后重置计数器 + } + } + } + debug('[BiliCookie] 周期性检查结束'); + }; + + /** + * @description 设置新的 Bilibili Cookie + * @param cookie Cookie 字符串 + * @param refreshToken (可选) Bilibili refresh token + */ + const setBiliCookie = async (cookie: string, refreshToken?: string): Promise => { + info('[BiliCookie] 正在设置新的 Bilibili Cookie...'); + // 1. 验证新 Cookie 的有效性 + const { valid } = await _checkCookieValidity(cookie); + + if (valid) { + // 2. 如果有效,则持久化存储 + const dataToStore: BiliCookieStoreData = { + cookie, + ...(refreshToken && { refreshToken }), // 仅在提供时添加 refreshToken + lastRefresh: new Date() // 更新刷新时间戳 + }; + try { + await biliCookieStore.set(dataToStore); + info('[BiliCookie] 新 Bilibili Cookie 已验证并保存'); + _updateCookieState(true, true); // 更新状态为存在且有效 + } catch (err) { + error('[BiliCookie] 保存 Bilibili Cookie 失败: ' + String(err)); + // 保存失败,状态回滚或标记为错误?暂时保持验证结果 + _updateCookieState(true, false); // Cookie 存在但保存失败,标记无效可能更安全 + throw new Error("保存 Bilibili Cookie 失败"); // 向上抛出错误 + } + } else { + // 新 Cookie 无效,不保存,并标记状态 + _updateCookieState(hasBiliCookie.value, false); // 保持 hasBiliCookie 原样或设为 false?取决于策略 + warn('[BiliCookie] 尝试设置的 Bilibili Cookie 无效,未保存'); + // 可以选择抛出错误,让调用者知道设置失败 + // throw new Error("设置的 Bilibili Cookie 无效"); + } + }; + + /** + * @description 获取当前存储的 Bilibili Cookie (不保证有效性) + * @returns Promise Cookie 字符串或 undefined + */ + const getBiliCookie = async (): Promise => { + const data = await biliCookieStore.get(); + return data?.cookie; + }; + + /** + * @description 退出登录,清除 Bilibili Cookie 及相关状态和缓存 + */ + const logout = async (): Promise => { + info('[BiliCookie] 用户请求退出登录...'); + // 停止定时检查器 + if (_checkIntervalId) { + clearInterval(_checkIntervalId); + _checkIntervalId = null; + debug('[BiliCookie] 定时检查已停止'); + } + // 清除 Cookie 存储 + try { + await biliCookieStore.delete(); + } catch (err) { + error('[BiliCookie] 清除 Bilibili Cookie 存储失败: ' + String(err)); + } + // 清除用户信息缓存 + await _clearUserInfoCache(); + // 重置状态变量 + _updateCookieState(false, false); + // Cookie Cloud 状态是否重置?取决于产品逻辑,暂时保留 + // cookieCloudState.value = 'unset'; + // 重置初始化标志,允许重新 init + _isInitialized = false; + _checkCounter = 0; // 重置计数器 + info('[BiliCookie] 退出登录完成,状态已重置'); + }; + + /** + * @description 设置并验证 CookieCloud 配置 + * @param config CookieCloud 配置数据 + * @throws 如果配置无效或从 CookieCloud 获取/验证 Cookie 失败 + */ + const setCookieCloudConfig = async (config: CookieCloudConfig): Promise => { + info('[BiliCookie] 正在设置新的 CookieCloud 配置...'); + cookieCloudState.value = 'syncing'; // 标记为尝试同步/验证中 + + try { + // 1. 使用新配置尝试从 Cloud 获取 Cookie + const cookieString = await _fetchAndDecryptFromCloud(config); + // 2. 验证获取到的 Cookie + const validationResult = await _checkCookieValidity(cookieString); + + if (validationResult.valid && validationResult.data) { + // 3. 如果验证成功,保存 CookieCloud 配置 + await cookieCloudStore.set(config); + info('[BiliCookie] CookieCloud 配置验证成功并已保存. 用户:' + validationResult.data.name); + cookieCloudState.value = 'valid'; // 标记为有效 + + // 4. 使用从 Cloud 获取的有效 Cookie 更新本地 Cookie + // 注意:这里直接调用 setBiliCookie 会再次进行验证,但确保状态一致性 + await setBiliCookie(cookieString); + // 重置检查计数器,以便下次正常检查 + _checkCounter = 0; + } else { + // 从 Cloud 获取的 Cookie 无效 + cookieCloudState.value = 'invalid'; + warn('[BiliCookie] 使用新 CookieCloud 配置获取的 Cookie 无效'); + throw new Error('CookieCloud 配置无效:获取到的 Bilibili Cookie 无法通过验证'); + } + } catch (err) { + error('[BiliCookie] 设置 CookieCloud 配置失败: ' + String(err)); + cookieCloudState.value = 'invalid'; // 出错则标记为无效 + // 向上抛出错误,通知调用者失败 + throw err; // err 已经是 Error 类型或被包装过 + } + }; + async function clearCookieCloudConfig() { + info('[BiliCookie] 清除 CookieCloud 配置...'); + cookieCloudState.value = 'unset'; + // 清除持久化存储 + await cookieCloudStore.delete().catch(err => { + error('[BiliCookie] 清除 CookieCloud 配置失败: ' + String(err)); + }); + } + + // --- 返回 Store 的公开接口 --- + return { + // 只读状态和计算属性 + hasBiliCookie: computed(() => hasBiliCookie.value), // 只读 ref + isCookieValid: computed(() => isCookieValid.value), // 只读 ref + cookieCloudState: computed(() => cookieCloudState.value), // 只读 ref + uId: computed(() => uId.value), // 只读 ref + userInfo, // computed 属性本身就是只读的 + + // 方法 + init, + check, // 暴露 check 方法,允许手动触发检查 (例如,应用从后台恢复) + setBiliCookie, + getBiliCookie, // 获取原始 cookie 字符串的方法 + logout, + setCookieCloudConfig, + clearCookieCloudConfig, + // 注意:不再直接暴露 fetchBiliCookieFromCloud,其逻辑已整合到内部同步和设置流程中 + }; +}); + +// --- HMR 支持 --- +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useBiliCookie, import.meta.hot)); +} \ No newline at end of file diff --git a/src/client/store/useSettings.ts b/src/client/store/useSettings.ts new file mode 100644 index 0000000..758672b --- /dev/null +++ b/src/client/store/useSettings.ts @@ -0,0 +1,45 @@ +import { useTauriStore } from './useTauriStore'; + +export type NotificationType = 'question-box' | 'danmaku'; +export type NotificationSettings = { + enableTypes: NotificationType[]; +}; +export type VTsuruClientSettings = { + useDanmakuClientType: 'openlive' | 'direct'; + fallbackToOpenLive: boolean; + + danmakuHistorySize: number; + loginType: 'qrcode' | 'cookiecloud' + + enableNotification: boolean; + notificationSettings: NotificationSettings; +}; + +export const useSettings = defineStore('settings', () => { + const store = useTauriStore().getTarget('settings'); + const defaultSettings: VTsuruClientSettings = { + useDanmakuClientType: 'openlive', + fallbackToOpenLive: true, + + danmakuHistorySize: 100, + loginType: 'qrcode', + enableNotification: true, + notificationSettings: { + enableTypes: ['question-box', 'danmaku'], + }, + }; + const settings = ref(Object.assign({}, defaultSettings)); + + async function init() { + settings.value = (await store.get()) || Object.assign({}, defaultSettings); + settings.value.notificationSettings ??= defaultSettings.notificationSettings; + settings.value.notificationSettings.enableTypes ??= [ 'question-box', 'danmaku' ]; + } + async function save() { + await store.set(settings.value); + } + + return { init, save, settings }; +}); + +if (import.meta.hot) import.meta.hot.accept(acceptHMRUpdate(useSettings, import.meta.hot)); diff --git a/src/client/store/useTauriStore.ts b/src/client/store/useTauriStore.ts new file mode 100644 index 0000000..ee3f3f3 --- /dev/null +++ b/src/client/store/useTauriStore.ts @@ -0,0 +1,53 @@ +import { LazyStore } from '@tauri-apps/plugin-store'; + +export class StoreTarget { + constructor(key: string, target: LazyStore, defaultValue?: T) { + this.target = target; + this.key = key; + this.defaultValue = defaultValue; + } + protected target: LazyStore; + protected defaultValue: T | undefined; + + protected key: string; + + async set(value: T) { + return await this.target.set(this.key, value); + } + async get(): Promise { + const result = await this.target.get(this.key); + + if (result === undefined && this.defaultValue !== undefined) { + await this.set(this.defaultValue); + return this.defaultValue as T; + } + return result; + } + + async delete() { + return await this.target.delete(this.key); + } +} + +export const useTauriStore = defineStore('tauri', () => { + const store = new LazyStore('vtsuru.data.json', { + autoSave: true, + }); + async function set(key: string, value: any) { + await store.set(key, value); + } + async function get(key: string) { + return await store.get(key); + } + function getTarget(key: string, defaultValue?: T) { + return new StoreTarget(key, store, defaultValue); + } + return { + store, + set, + get, + getTarget, + }; +}); + +if (import.meta.hot) import.meta.hot.accept(acceptHMRUpdate(useTauriStore, import.meta.hot)); diff --git a/src/components.d.ts b/src/components.d.ts index 416af54..42de775 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -17,10 +17,29 @@ declare module 'vue' { FeedbackItem: typeof import('./components/FeedbackItem.vue')['default'] LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default'] MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default'] + NAlert: typeof import('naive-ui')['NAlert'] + NAvatar: typeof import('naive-ui')['NAvatar'] + NButton: typeof import('naive-ui')['NButton'] + NCard: typeof import('naive-ui')['NCard'] + NCheckbox: typeof import('naive-ui')['NCheckbox'] + NFlex: typeof import('naive-ui')['NFlex'] NFormItemG: typeof import('naive-ui')['NFormItemG'] NFormItemGi: typeof import('naive-ui')['NFormItemGi'] + NGridItem: typeof import('naive-ui')['NGridItem'] + NH4: typeof import('naive-ui')['NH4'] NIcon: typeof import('naive-ui')['NIcon'] + NImage: typeof import('naive-ui')['NImage'] + NInput: typeof import('naive-ui')['NInput'] + NLayoutContent: typeof import('naive-ui')['NLayoutContent'] + NPopconfirm: typeof import('naive-ui')['NPopconfirm'] + NRadioButton: typeof import('naive-ui')['NRadioButton'] + NRadioGroup: typeof import('naive-ui')['NRadioGroup'] + NScrollbar: typeof import('naive-ui')['NScrollbar'] + NSpace: typeof import('naive-ui')['NSpace'] + NTab: typeof import('naive-ui')['NTab'] NTag: typeof import('naive-ui')['NTag'] + NText: typeof import('naive-ui')['NText'] + NTooltip: typeof import('naive-ui')['NTooltip'] PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default'] PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default'] PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default'] diff --git a/src/components/TempComponent.vue b/src/components/TempComponent.vue index e5e90d8..5571540 100644 --- a/src/components/TempComponent.vue +++ b/src/components/TempComponent.vue @@ -15,6 +15,7 @@ onMounted(() => { window.$message = useMessage() window.$route = useRoute() window.$modal = useModal() + window.$notification = useNotification() const providerStore = useLoadingBarStore() providerStore.setLoadingBar(window.$loadingBar) }) diff --git a/src/data/DanmakuClients/BaseDanmakuClient.ts b/src/data/DanmakuClients/BaseDanmakuClient.ts index beccdaf..352ee4d 100644 --- a/src/data/DanmakuClients/BaseDanmakuClient.ts +++ b/src/data/DanmakuClients/BaseDanmakuClient.ts @@ -12,6 +12,7 @@ export default abstract class BaseDanmakuClient { 'padding' public abstract type: 'openlive' | 'direct' + public abstract serverUrl: string public eventsAsModel: { danmaku: ((arg1: EventModel, arg2?: any) => void)[] @@ -118,6 +119,7 @@ export default abstract class BaseDanmakuClient { this.client.close() this.client = null } + this.serverUrl = chatClient.connection.ws.ws.url return { success: !isError, message: errorMsg diff --git a/src/data/DanmakuClients/DirectClient.ts b/src/data/DanmakuClients/DirectClient.ts index b2ade79..c9652e2 100644 --- a/src/data/DanmakuClients/DirectClient.ts +++ b/src/data/DanmakuClients/DirectClient.ts @@ -11,6 +11,7 @@ export type DirectClientAuthInfo = { * 未实现除raw事件外的所有事件 */ export default class DirectClient extends BaseDanmakuClient { + public serverUrl: string = 'wss://broadcastlv.chat.bilibili.com/sub'; public onDanmaku(command: any): void { throw new Error('Method not implemented.') } diff --git a/src/data/DanmakuClients/OpenLiveClient.ts b/src/data/DanmakuClients/OpenLiveClient.ts index f9d6ade..3d5fe66 100644 --- a/src/data/DanmakuClients/OpenLiveClient.ts +++ b/src/data/DanmakuClients/OpenLiveClient.ts @@ -7,6 +7,7 @@ import { OPEN_LIVE_API_URL } from '../constants' import BaseDanmakuClient from './BaseDanmakuClient' export default class OpenLiveClient extends BaseDanmakuClient { + public serverUrl: string = ''; constructor(auth?: AuthInfo) { super() this.authInfo = auth diff --git a/src/main.ts b/src/main.ts index 1361c93..d5c68cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,10 @@ import { useAuthStore } from './store/useAuthStore' import { useNotificationStore } from './store/useNotificationStore' const pinia = createPinia() +export const getPinia = () => pinia + +const app = createApp(App) +app.use(router).use(pinia).mount('#app') QueryGetAPI(`${BASE_API_URL}vtsuru/version`) .then((version) => { @@ -122,9 +126,6 @@ QueryGetAPI(`${BASE_API_URL}vtsuru/version`) UpdateAccountLoop() }) -const app = createApp(App) -app.use(router).use(pinia).mount('#app') - let currentVersion: string let isHaveNewVersion = false diff --git a/src/router/client.ts b/src/router/client.ts new file mode 100644 index 0000000..9a902cd --- /dev/null +++ b/src/router/client.ts @@ -0,0 +1,38 @@ +export default { + path: '/client', + name: 'client', + children: [ + { + path: '', + name: 'client-index', + component: () => import('@/client/ClientIndex.vue'), + meta: { + title: '首页', + } + }, + { + path: 'fetcher', + name: 'client-fetcher', + component: () => import('@/client/ClientFetcher.vue'), + meta: { + title: 'EventFetcher', + } + }, + { + path: 'settings', + name: 'client-settings', + component: () => import('@/client/ClientSettings.vue'), + meta: { + title: '设置', + } + }, + { + path: 'test', + name: 'client-test', + component: () => import('@/client/ClientTest.vue'), + meta: { + title: '测试', + } + }, + ] +} diff --git a/src/router/index.ts b/src/router/index.ts index 076ffdb..59c1a25 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -6,6 +6,7 @@ import user from './user' import obs from './obs' import open_live from './open_live' import singlePage from './singlePage' +import client from './client'; const routes: Array = [ { @@ -88,6 +89,7 @@ const routes: Array = [ manage, obs, open_live, + client, { path: '/@:id', name: 'user', diff --git a/src/store/useWebFetcher.ts b/src/store/useWebFetcher.ts index 04fdeff..bf35b89 100644 --- a/src/store/useWebFetcher.ts +++ b/src/store/useWebFetcher.ts @@ -1,270 +1,402 @@ -import { BASE_HUB_URL } 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 { useLocalStorage } from '@vueuse/core' -import { format } from 'date-fns' -import { defineStore } from 'pinia' -import { computed, ref } from 'vue' -import { useRoute } from 'vue-router' -import { compress } from 'brotli-compress' +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 * as signalR from '@microsoft/signalr'; +import * as msgpack from '@microsoft/signalr-protocol-msgpack'; +import { 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 { useWebRTC } from './useRTC'; export const useWebFetcher = defineStore('WebFetcher', () => { - const cookie = useLocalStorage('JWT_Token', '') - const route = useRoute() - const startedAt = ref() + const cookie = useLocalStorage('JWT_Token', ''); + const route = useRoute(); + const account = useAccount(); + const rtc = useWebRTC(); + const webfetcherType = ref<'openlive' | 'direct'>('openlive'); // 弹幕客户端类型 + // --- 连接与状态 --- + const state = ref<'disconnected' | 'connecting' | 'connected'>('disconnected'); // SignalR 连接状态 + const startedAt = ref(); // 本次启动时间 + const signalRClient = shallowRef(); // SignalR 客户端实例 (浅响应) + const client = shallowRef(); // 弹幕客户端实例 (浅响应) + let timer: any; // 事件发送定时器 + let disconnectedByServer = false; + let isFromClient = false; // 是否由Tauri客户端启动 - const client = ref() - const signalRClient = ref() + // --- 新增: 详细状态与信息 --- + /** 弹幕客户端内部状态 */ + const danmakuClientState = ref<'stopped' | 'connecting' | 'connected'>('stopped'); // 更详细的弹幕客户端状态 + /** 弹幕服务器连接地址 */ + const danmakuServerUrl = ref(); + /** SignalR 连接 ID */ + const signalRConnectionId = ref(); + // const heartbeatLatency = ref(null); // 心跳延迟暂不实现,复杂度较高 + + // --- 事件处理 --- + const events: string[] = []; // 待发送事件队列 + + // --- 新增: 会话统计 (在 Start 时重置) --- + /** 本次会话处理的总事件数 */ + const sessionEventCount = ref(0); + /** 本次会话各类型事件计数 */ + const sessionEventTypeCounts = ref<{ [key: string]: number; }>({}); + /** 本次会话成功上传次数 */ + const successfulUploads = ref(0); + /** 本次会话失败上传次数 */ + const failedUploads = ref(0); + /** 本次会话发送的总字节数 (压缩后) */ + const bytesSentSession = ref(0); + + const prefix = computed(() => isFromClient ? '[web-fetcher-iframe] ' : '[web-fetcher] '); - const events: string[] = [] - const isStarted = ref(false) - let timer: any - let disconnectedByServer = false - let useCookie = false /** - * 是否来自Tauri客户端 + * 启动 WebFetcher 服务 */ - let isFromClient = false - const prefix = computed(() => { - if (isFromClient) { - return '[web-fetcher-iframe] ' - } - return '[web-fetcher] ' - }) - async function restartDanmakuClient( - type: 'openlive' | 'direct', - directAuthInfo?: DirectClientAuthInfo - ) { - console.log(prefix.value + '正在重启弹幕客户端...') - if ( - client.value?.state === 'connected' || - client.value?.state === 'connecting' - ) { - client.value.Stop() - } - return await connectDanmakuClient(type, directAuthInfo) - } async function Start( type: 'openlive' | 'direct' = 'openlive', directAuthInfo?: DirectClientAuthInfo, _isFromClient: boolean = false - ): Promise<{ success: boolean; message: string }> { - if (isStarted.value) { - startedAt.value = new Date() - return { success: true, message: '已启动' } + ): Promise<{ success: boolean; message: string; }> { + if (state.value === 'connected' || state.value === 'connecting') { + logInfo(prefix.value + '已经启动,无需重复启动'); + return { success: true, message: '已启动' }; } - const result = await navigator.locks.request( - 'webFetcherStart', - async () => { - isFromClient = _isFromClient - while (!(await connectSignalR())) { - console.log(prefix.value + '连接失败, 5秒后重试') - await new Promise((resolve) => setTimeout(resolve, 5000)) - } - let result = await connectDanmakuClient(type, directAuthInfo) - while (!result?.success) { - console.log(prefix.value + '弹幕客户端启动失败, 5秒后重试') - await new Promise((resolve) => setTimeout(resolve, 5000)) - result = await connectDanmakuClient(type, directAuthInfo) - } - isStarted.value = true - disconnectedByServer = false - return result + webfetcherType.value = type; // 设置弹幕客户端类型 + // 重置会话统计数据 + resetSessionStats(); + startedAt.value = new Date(); + isFromClient = _isFromClient; + state.value = 'connecting'; // 设置为连接中状态 + + // 使用 navigator.locks 确保同一时间只有一个 Start 操作执行 + const result = await navigator.locks.request('webFetcherStartLock', async () => { + logInfo(prefix.value + '开始启动...'); + while (!(await connectSignalR())) { + logInfo(prefix.value + '连接 SignalR 失败, 5秒后重试'); + await new Promise((resolve) => setTimeout(resolve, 5000)); + // 如果用户手动停止,则退出重试循环 + if (state.value === 'disconnected') return { success: false, message: '用户手动停止' }; } - ) - return result - } - function Stop() { - if (!isStarted.value) { - return + + let danmakuResult = await connectDanmakuClient(type, directAuthInfo); + while (!danmakuResult?.success) { + logInfo(prefix.value + '弹幕客户端启动失败, 5秒后重试'); + await new Promise((resolve) => setTimeout(resolve, 5000)); + // 如果用户手动停止,则退出重试循环 + if (state.value === 'disconnected') return { success: false, message: '用户手动停止' }; + danmakuResult = await connectDanmakuClient(type, directAuthInfo); + } + + // 只有在两个连接都成功后才设置为 connected + state.value = 'connected'; + disconnectedByServer = false; + logInfo(prefix.value + '启动成功'); + return { success: true, message: '启动成功' }; + }); + + // 如果启动过程中因为手动停止而失败,需要确保状态是 disconnected + if (!result.success) { + Stop(); // 确保清理资源 + return { success: false, message: result.message || '启动失败' }; } - isStarted.value = false - client.value?.Stop() - client.value = undefined - if (timer) { - clearInterval(timer) - timer = undefined - } - signalRClient.value?.stop() - signalRClient.value = undefined - startedAt.value = undefined + + return result; } - /************* ✨ Codeium Command ⭐ *************/ + /** - * Connects to the danmaku client based on the specified type. - * - * @param type - The type of danmaku client to connect, either 'openlive' or 'direct'. - * @param directConnectInfo - Optional authentication information required when connecting to a 'direct' type client. - * It should include a token, roomId, tokenUserId, and buvid. - * - * @returns A promise that resolves to an object containing a success flag and a message. - * If the connection and client start are successful, the client starts listening to danmaku events. - * If the connection fails or the authentication information is not provided for a 'direct' type client, - * the function returns with a failure message. + * 停止 WebFetcher 服务 + */ + function Stop() { + if (state.value === 'disconnected') return; + + logInfo(prefix.value + '正在停止...'); + state.value = 'disconnected'; // 立即设置状态,防止重连逻辑触发 + + // 清理定时器 + if (timer) { clearInterval(timer); timer = undefined; } + + // 停止弹幕客户端 + client.value?.Stop(); + client.value = undefined; + danmakuClientState.value = 'stopped'; + danmakuServerUrl.value = undefined; + + // 停止 SignalR 连接 + signalRClient.value?.stop(); + signalRClient.value = undefined; + signalRConnectionId.value = undefined; + + // 清理状态 + startedAt.value = undefined; + events.length = 0; // 清空事件队列 + // resetSessionStats(); // 会话统计在下次 Start 时重置 + + logInfo(prefix.value + '已停止'); + } + + /** 重置会话统计数据 */ + function resetSessionStats() { + sessionEventCount.value = 0; + sessionEventTypeCounts.value = {}; + successfulUploads.value = 0; + failedUploads.value = 0; + bytesSentSession.value = 0; + } + + /** + * 连接弹幕客户端 */ - /****** 3431380f-29f6-41b0-801a-7f081b59b4ff *******/ async function connectDanmakuClient( type: 'openlive' | 'direct', - directConnectInfo?: { - token: string - roomId: number - tokenUserId: number - buvid: string - } + directConnectInfo?: DirectClientAuthInfo ) { - if ( - client.value?.state === 'connected' || - client.value?.state === 'connecting' - ) { - return { success: true, message: '弹幕客户端已启动' } + if (client.value?.state === 'connected' || client.value?.state === 'connecting') { + logInfo(prefix.value + '弹幕客户端已连接或正在连接'); + return { success: true, message: '弹幕客户端已启动' }; } - console.log(prefix.value + '正在连接弹幕客户端...') + + logInfo(prefix.value + '正在连接弹幕客户端...'); + danmakuClientState.value = 'connecting'; + + // 如果实例存在但已停止,先清理 + if (client.value?.state === 'disconnected') { + client.value = undefined; + } + + // 创建实例并添加事件监听 (仅在首次创建时) if (!client.value) { - //只有在没有客户端的时候才创建, 并添加事件 - if (type == 'openlive') { - client.value = new OpenLiveClient() + if (type === 'openlive') { + client.value = new OpenLiveClient(); } else { if (!directConnectInfo) { - return { success: false, message: '未提供弹幕客户端认证信息' } + danmakuClientState.value = 'stopped'; + logError(prefix.value + '未提供直连弹幕客户端认证信息'); + return { success: false, message: '未提供弹幕客户端认证信息' }; } - client.value = new DirectClient(directConnectInfo) + client.value = new DirectClient(directConnectInfo); + // 直连地址通常包含 host 和 port,可以从 directConnectInfo 获取 + //danmakuServerUrl.value = `${directConnectInfo.host}:${directConnectInfo.port}`; } - client.value?.on('all', (data) => onGetDanmakus(data)) + // 监听所有事件,用于处理和转发 + client.value?.on('all', onGetDanmakus); } - const result = await client.value?.Start() + + // 启动客户端连接 + const result = await client.value?.Start(); + if (result?.success) { - console.log(prefix.value + '加载完成, 开始监听弹幕') - timer ??= setInterval(() => { - sendEvents() - }, 1500) + logInfo(prefix.value + '弹幕客户端连接成功, 开始监听弹幕'); + danmakuClientState.value = 'connected'; // 明确设置状态 + danmakuServerUrl.value = client.value.serverUrl; // 获取服务器地址 + // 启动事件发送定时器 (如果之前没有启动) + timer ??= setInterval(sendEvents, 1500); // 每 1.5 秒尝试发送一次事件 } else { - console.log(prefix.value + '弹幕客户端启动失败: ' + result?.message) + logError(prefix.value + '弹幕客户端启动失败: ' + result?.message); + danmakuClientState.value = 'stopped'; + danmakuServerUrl.value = undefined; + client.value = undefined; // 启动失败,清理实例,下次会重建 } - return result + return result; } + + /** + * 连接 SignalR 服务器 + */ async function connectSignalR() { - console.log(prefix.value + '正在连接到 vtsuru 服务器...') + if (signalRClient.value && signalRClient.value.state !== signalR.HubConnectionState.Disconnected) { + logInfo(prefix.value + "SignalR 已连接或正在连接"); + return true; + } + + logInfo(prefix.value + '正在连接到 vtsuru 服务器...'); const connection = new signalR.HubConnectionBuilder() - .withUrl(BASE_HUB_URL + 'web-fetcher?token=' + route.query.token, { - headers: { - Authorization: `Bearer ${cookie.value}` - }, + .withUrl(BASE_HUB_URL + 'web-fetcher?token=' + (route.query.token ?? account.value.token), { // 使用 account.token + headers: { Authorization: `Bearer ${cookie.value}` }, skipNegotiation: true, transport: signalR.HttpTransportType.WebSockets }) - .withAutomaticReconnect([0, 2000, 10000, 30000]) - .withHubProtocol(new msgpack.MessagePackHubProtocol()) - .build() - connection.on('Disconnect', (reason: unknown) => { - console.log(prefix.value + '被服务器断开连接: ' + reason) - disconnectedByServer = true - connection.stop() - signalRClient.value = undefined - }) - /*connection.on('ConnectClient', async () => { - if (client?.state === 'connected') { - return - } - let result = await connectDanmakuClient() - while (!result?.success) { - console.log(prefix.value + '弹幕客户端启动失败, 5秒后重试') - await new Promise((resolve) => setTimeout(resolve, 5000)) - result = await connectDanmakuClient() - } - isStarted.value = true - disconnectedByServer = false - })*/ + .withAutomaticReconnect([0, 2000, 10000, 30000]) // 自动重连策略 + .withHubProtocol(new msgpack.MessagePackHubProtocol()) // 使用 MessagePack 协议 + .build(); - connection.onclose(reconnect) - try { - await connection.start() - console.log(prefix.value + '已连接到 vtsuru 服务器') - await connection.send('Finished') + // --- SignalR 事件监听 --- + connection.onreconnecting(error => { + logInfo(prefix.value + `与服务器断开,正在尝试重连... ${error?.message || ''}`); + state.value = 'connecting'; // 更新状态为连接中 + signalRConnectionId.value = undefined; // 连接断开,ID失效 + }); + + connection.onreconnected(connectionId => { + logInfo(prefix.value + `与服务器重新连接成功! ConnectionId: ${connectionId}`); + signalRConnectionId.value = connectionId ?? undefined; + state.value = 'connected'; // 更新状态为已连接 + // 重连成功后可能需要重新发送标识 if (isFromClient) { - // 如果来自Tauri客户端,设置自己为VTsuru客户端 - await connection.send('SetAsVTsuruClient') + connection.send('SetAsVTsuruClient').catch(err => logError(prefix.value + "Send SetAsVTsuruClient failed: " + err)); } - signalRClient.value = connection - return true - } catch (e) { - console.log(prefix.value + '无法连接到 vtsuru 服务器: ' + e) - return false - } - } - async function reconnect() { - if (disconnectedByServer) { - return - } - try { - await signalRClient.value?.start() - await signalRClient.value?.send('Reconnected') - if (isFromClient) { - await signalRClient.value?.send('SetAsVTsuruClient') - } - console.log(prefix.value + '已重新连接') - } catch (err) { - console.log(err) - setTimeout(reconnect, 5000) // 如果连接失败,则每5秒尝试一次重新启动连接 - } - } - function onGetDanmakus(command: any) { - events.push(command) - } - async function sendEvents() { - if (signalRClient.value?.state !== 'Connected') { - return - } - let tempEvents: string[] = [] - let count = events.length - if (events.length > 20) { - tempEvents = events.slice(0, 20) - count = 20 - } else { - tempEvents = events - count = events.length - } - if (tempEvents.length > 0) { - const compressed = await compress( - new TextEncoder().encode( - JSON.stringify({ - Events: tempEvents.map((e) => - typeof e === 'string' ? e : JSON.stringify(e) - ), - Version: '1.0.0', - OSInfo: navigator.userAgent, - UseCookie: useCookie - }) - ) - ) - const result = await signalRClient.value?.invoke<{ - Success: boolean - Message: string - }>('UploadEventsCompressed', compressed) - if (result?.Success) { - events.splice(0, count) - console.log( - `[WEB-FETCHER] <${format(new Date(), 'HH:mm:ss')}> 上传了 ${count} 条弹幕` - ) + connection.send('Reconnected').catch(err => logError(prefix.value + "Send Reconnected failed: " + err)); + }); + + connection.onclose(async (error) => { + // 只有在不是由 Stop() 或服务器明确要求断开时才记录错误并尝试独立重连(虽然 withAutomaticReconnect 应该处理) + if (state.value !== 'disconnected' && !disconnectedByServer) { + logError(prefix.value + `与服务器连接关闭: ${error?.message || '未知原因'}. 自动重连将处理.`); + state.value = 'connecting'; // 标记为连接中,等待自动重连 + signalRConnectionId.value = undefined; + // withAutomaticReconnect 会处理重连,这里不需要手动调用 reconnect + } else if (disconnectedByServer) { + logInfo(prefix.value + `连接已被服务器关闭.`); + Stop(); // 服务器要求断开,则彻底停止 } else { - console.error(prefix.value + '上传弹幕失败: ' + result?.Message) + logInfo(prefix.value + `连接已手动关闭.`); } + }); + + connection.on('Disconnect', (reason: unknown) => { + logInfo(prefix.value + '被服务器断开连接: ' + reason); + disconnectedByServer = true; // 标记是服务器主动断开 + Stop(); // 服务器要求断开,调用 Stop 清理所有资源 + }); + + // --- 尝试启动连接 --- + 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 + 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); + signalRConnectionId.value = undefined; + signalRClient.value = undefined; + // state.value = 'disconnected'; // 保持 connecting 或由 Start 控制 + return false; } } + // async function reconnect() { // withAutomaticReconnect 存在时,此函数通常不需要手动调用 + // if (disconnectedByServer || state.value === 'disconnected') return; + // logInfo(prefix.value + '尝试手动重连...'); + // try { + // await signalRClient.value?.start(); + // logInfo(prefix.value + '手动重连成功'); + // signalRConnectionId.value = signalRClient.value?.connectionId ?? null; + // state.value = 'connected'; + // if (isFromClient) { + // await signalRClient.value?.send('SetAsVTsuruClient'); + // } + // await signalRClient.value?.send('Reconnected'); + // } catch (err) { + // logError(prefix.value + '手动重连失败: ' + err); + // setTimeout(reconnect, 10000); // 失败后10秒再次尝试 + // } + // } + + /** + * 接收到弹幕事件时的处理函数 + */ + function onGetDanmakus(command: any) { + if (isFromClient) { + // 1. 解析事件类型 + const eventType = getEventType(command); + + // 2. 记录到每日统计 (调用 statistics 模块) + recordEvent(eventType); + + // 3. 更新会话统计 + sessionEventCount.value++; + sessionEventTypeCounts.value[eventType] = (sessionEventTypeCounts.value[eventType] || 0) + 1; + } + // 4. 加入待发送队列 (确保是字符串) + const eventString = typeof command === 'string' ? command : JSON.stringify(command); + if (isDev) { + //console.log(prefix.value + '收到弹幕事件: ' + eventString); // 开发模式下打印所有事件 (可选) + } + if (events.length >= 10000) { + events.shift(); // 如果队列过长,移除最旧的事件 + } + events.push(eventString); + } + + /** + * 定期将队列中的事件发送到服务器 + */ + async function sendEvents() { + // 确保 SignalR 已连接 + if (!signalRClient.value || signalRClient.value.state !== signalR.HubConnectionState.Connected) { + return; + } + // 如果没有事件,则不发送 + if (events.length === 0) { + return; + } + + // 批量处理事件,每次最多发送20条 + const batchSize = 20; + const batch = events.slice(0, batchSize); + + try { + + const 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; + } else { + failedUploads.value++; + logError(prefix.value + '上传弹幕失败: ' + result?.Message); + } + } catch (err) { + failedUploads.value++; + logError(prefix.value + '发送事件时出错: ' + err); + } + } + + // --- 暴露给外部使用的状态和方法 --- return { Start, Stop, - restartDanmakuClient, - client, - signalRClient, - isStarted, - startedAt - } -}) + // restartDanmakuClient, // 如果需要重启单独的弹幕客户端,可以保留或实现 + + // 状态 + state, // Overall SignalR state + startedAt, + isStreaming: computed(() => streamingInfo.value?.status === 'streaming'), // 从 statistics 模块获取 + webfetcherType, + + // 连接详情 + danmakuClientState, + danmakuServerUrl, + //signalRConnectionId, + // heartbeatLatency, // 暂不暴露 + + // 会话统计 + sessionEventCount, + sessionEventTypeCounts, + successfulUploads, + failedUploads, + bytesSentSession, + + // 实例 (谨慎暴露,主要用于调试或特定场景) + signalRClient: computed(() => signalRClient.value), // 返回计算属性以防直接修改 + client: computed(() => client.value), + + }; +}); \ No newline at end of file diff --git a/src/views/client/ClientLayout.vue b/src/views/client/ClientLayout.vue deleted file mode 100644 index 2f6cd87..0000000 --- a/src/views/client/ClientLayout.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - \ No newline at end of file diff --git a/src/views/obs/WebFetcherOBS.vue b/src/views/obs/WebFetcherOBS.vue index e0fca9e..438ffa1 100644 --- a/src/views/obs/WebFetcherOBS.vue +++ b/src/views/obs/WebFetcherOBS.vue @@ -31,7 +31,7 @@ onMounted(async () => { rpc.expose('status', () => { return { - status: webFetcher.isStarted ? 'running' : 'stopped', + status: webFetcher.state, type: webFetcher.client?.type, roomId: webFetcher.client instanceof OpenLiveClient ? webFetcher.client.roomAuthInfo?.anchor_info.room_id : @@ -44,7 +44,8 @@ onMounted(async () => { rpc.expose('start', async (data: { type: 'openlive' | 'direct', directAuthInfo?: DirectClientAuthInfo, force: boolean }) => { console.log('[web-fetcher-iframe] 接收到 ' + (data.force ? '强制' : '') + '启动请求') - if (data.force && webFetcher.isStarted) { + if (data.force && webFetcher.state === 'connected') { + console.log('[web-fetcher-iframe] 强制启动, 停止当前实例') webFetcher.Stop() } return await webFetcher.Start(data.type, data.directAuthInfo, true).then((result) => { @@ -73,9 +74,9 @@ onMounted(async () => { } setTimeout(() => { // @ts-expect-error obs的东西 - if (!webFetcher.isStarted && window.obsstudio) { + if (webFetcher.state !== 'connected' && window.obsstudio) { timer = setInterval(() => { - if (webFetcher.isStarted) { + if (webFetcher.state === 'connected') { return } @@ -99,7 +100,7 @@ onUnmounted(() => {
diff --git a/src/views/view/songListTemplate/TraditionalSongListTemplate.vue b/src/views/view/songListTemplate/TraditionalSongListTemplate.vue index be63ca2..43ea74b 100644 --- a/src/views/view/songListTemplate/TraditionalSongListTemplate.vue +++ b/src/views/view/songListTemplate/TraditionalSongListTemplate.vue @@ -644,8 +644,9 @@ export const Config = defineTemplateConfig([ -
@@ -739,7 +740,7 @@ export const Config = defineTemplateConfig([
-
+
@@ -1116,7 +1117,7 @@ html.dark .song-list-container { /* min-height: 200px; */ /* Might not be needed if max-height is set */ border-radius: 8px; /* Scrollbar styling specific to this inner table scroll if needed */ - /* ... */ + scroll-behavior: smooth; }